diff --git a/app.json b/app.json new file mode 100644 index 0000000..c9e8c8a --- /dev/null +++ b/app.json @@ -0,0 +1,3 @@ +{ + "expo": {} +} \ No newline at end of file diff --git a/boost.json b/boost.json new file mode 100644 index 0000000..8c6f178 --- /dev/null +++ b/boost.json @@ -0,0 +1,6 @@ +{ + "agents": [ + "cursor" + ], + "guidelines": [] +} diff --git a/docker/8.0/Dockerfile b/docker/8.0/Dockerfile new file mode 100644 index 0000000..32dc7cd --- /dev/null +++ b/docker/8.0/Dockerfile @@ -0,0 +1,72 @@ +FROM ubuntu:24.04 + +LABEL maintainer="Taylor Otwell" + +ARG WWWGROUP +ARG NODE_VERSION=24 +ARG POSTGRES_VERSION=18 + +WORKDIR /var/www/html + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC +ENV LANG=C.UTF-8 +ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80" +ENV SUPERVISOR_PHP_USER="sail" + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \ + echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \ + echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom + +RUN apt-get update && apt-get upgrade -y \ + && mkdir -p /etc/apt/keyrings \ + && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \ + && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /usr/share/keyrings/ppa_ondrej_php.gpg > /dev/null \ + && echo "deb [signed-by=/usr/share/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ + && apt-get update \ + && apt-get install -y libgd3 php8.0-cli php8.0-dev \ + php8.0-pgsql php8.0-sqlite3 php8.0-gd php8.0-imagick \ + php8.0-curl php8.0-memcached php8.0-mongodb \ + php8.0-imap php8.0-mysql php8.0-mbstring \ + php8.0-xml php8.0-zip php8.0-bcmath php8.0-soap \ + php8.0-intl php8.0-readline php8.0-pcov \ + php8.0-msgpack php8.0-igbinary php8.0-ldap \ + php8.0-redis php8.0-swoole php8.0-xdebug \ + && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y nodejs \ + && npm install -g npm \ + && npm install -g bun \ + && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /usr/share/keyrings/yarnkey.gpg >/dev/null \ + && echo "deb [signed-by=/usr/share/keyrings/yarnkey.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \ + && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/pgdg.gpg >/dev/null \ + && echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y yarn \ + && apt-get install -y mysql-client \ + && apt-get install -y postgresql-client-$POSTGRES_VERSION \ + && apt-get -y autoremove \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +RUN update-alternatives --set php /usr/bin/php8.0 + +RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.0 + +RUN userdel -r ubuntu +RUN groupadd --force -g $WWWGROUP sail +RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail +RUN git config --global --add safe.directory /var/www/html + +COPY start-container /usr/local/bin/start-container +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY php.ini /etc/php/8.0/cli/conf.d/99-sail.ini +RUN chmod +x /usr/local/bin/start-container + +EXPOSE 80/tcp + +ENTRYPOINT ["start-container"] diff --git a/docker/8.0/php.ini b/docker/8.0/php.ini new file mode 100644 index 0000000..0d8ce9e --- /dev/null +++ b/docker/8.0/php.ini @@ -0,0 +1,5 @@ +[PHP] +post_max_size = 100M +upload_max_filesize = 100M +variables_order = EGPCS +pcov.directory = . diff --git a/docker/8.0/start-container b/docker/8.0/start-container new file mode 100644 index 0000000..40c55df --- /dev/null +++ b/docker/8.0/start-container @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then + echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'." + exit 1 +fi + +if [ ! -z "$WWWUSER" ]; then + usermod -u $WWWUSER sail +fi + +if [ ! -d /.composer ]; then + mkdir /.composer +fi + +chmod -R ugo+rw /.composer + +if [ $# -gt 0 ]; then + if [ "$SUPERVISOR_PHP_USER" = "root" ]; then + exec "$@" + else + exec gosu $WWWUSER "$@" + fi +else + exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf +fi diff --git a/docker/8.0/supervisord.conf b/docker/8.0/supervisord.conf new file mode 100644 index 0000000..656da8a --- /dev/null +++ b/docker/8.0/supervisord.conf @@ -0,0 +1,14 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:php] +command=%(ENV_SUPERVISOR_PHP_COMMAND)s +user=%(ENV_SUPERVISOR_PHP_USER)s +environment=LARAVEL_SAIL="1" +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/docker/8.1/Dockerfile b/docker/8.1/Dockerfile new file mode 100644 index 0000000..90dff44 --- /dev/null +++ b/docker/8.1/Dockerfile @@ -0,0 +1,71 @@ +FROM ubuntu:24.04 + +LABEL maintainer="Taylor Otwell" + +ARG WWWGROUP +ARG NODE_VERSION=24 +ARG POSTGRES_VERSION=18 + +WORKDIR /var/www/html + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC +ENV LANG=C.UTF-8 +ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80" +ENV SUPERVISOR_PHP_USER="sail" + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \ + echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \ + echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom + +RUN apt-get update && apt-get upgrade -y \ + && mkdir -p /etc/apt/keyrings \ + && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \ + && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /usr/share/keyrings/ppa_ondrej_php.gpg > /dev/null \ + && echo "deb [signed-by=/usr/share/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ + && apt-get update \ + && apt-get install -y libgd3 php8.1-cli php8.1-dev \ + php8.1-pgsql php8.1-sqlite3 php8.1-gd php8.1-imagick \ + php8.1-curl php8.1-mongodb \ + php8.1-imap php8.1-mysql php8.1-mbstring \ + php8.1-xml php8.1-zip php8.1-bcmath php8.1-soap \ + php8.1-intl php8.1-readline \ + php8.1-ldap \ + php8.1-msgpack php8.1-igbinary php8.1-redis php8.1-swoole \ + php8.1-memcached php8.1-pcov php8.1-xdebug \ + && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y nodejs \ + && npm install -g npm \ + && npm install -g bun \ + && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /usr/share/keyrings/yarn.gpg >/dev/null \ + && echo "deb [signed-by=/usr/share/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \ + && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/pgdg.gpg >/dev/null \ + && echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y yarn \ + && apt-get install -y mysql-client \ + && apt-get install -y postgresql-client-$POSTGRES_VERSION \ + && apt-get -y autoremove \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.1 + +RUN userdel -r ubuntu +RUN groupadd --force -g $WWWGROUP sail +RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail +RUN git config --global --add safe.directory /var/www/html + +COPY start-container /usr/local/bin/start-container +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY php.ini /etc/php/8.1/cli/conf.d/99-sail.ini +RUN chmod +x /usr/local/bin/start-container + +EXPOSE 80/tcp + +ENTRYPOINT ["start-container"] diff --git a/docker/8.1/php.ini b/docker/8.1/php.ini new file mode 100644 index 0000000..0d8ce9e --- /dev/null +++ b/docker/8.1/php.ini @@ -0,0 +1,5 @@ +[PHP] +post_max_size = 100M +upload_max_filesize = 100M +variables_order = EGPCS +pcov.directory = . diff --git a/docker/8.1/start-container b/docker/8.1/start-container new file mode 100644 index 0000000..40c55df --- /dev/null +++ b/docker/8.1/start-container @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then + echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'." + exit 1 +fi + +if [ ! -z "$WWWUSER" ]; then + usermod -u $WWWUSER sail +fi + +if [ ! -d /.composer ]; then + mkdir /.composer +fi + +chmod -R ugo+rw /.composer + +if [ $# -gt 0 ]; then + if [ "$SUPERVISOR_PHP_USER" = "root" ]; then + exec "$@" + else + exec gosu $WWWUSER "$@" + fi +else + exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf +fi diff --git a/docker/8.1/supervisord.conf b/docker/8.1/supervisord.conf new file mode 100644 index 0000000..656da8a --- /dev/null +++ b/docker/8.1/supervisord.conf @@ -0,0 +1,14 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:php] +command=%(ENV_SUPERVISOR_PHP_COMMAND)s +user=%(ENV_SUPERVISOR_PHP_USER)s +environment=LARAVEL_SAIL="1" +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/docker/8.2/Dockerfile b/docker/8.2/Dockerfile new file mode 100644 index 0000000..258a287 --- /dev/null +++ b/docker/8.2/Dockerfile @@ -0,0 +1,71 @@ +FROM ubuntu:24.04 + +LABEL maintainer="Taylor Otwell" + +ARG WWWGROUP +ARG NODE_VERSION=24 +ARG POSTGRES_VERSION=18 + +WORKDIR /var/www/html + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC +ENV LANG=C.UTF-8 +ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80" +ENV SUPERVISOR_PHP_USER="sail" + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \ + echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \ + echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom + +RUN apt-get update && apt-get upgrade -y \ + && mkdir -p /etc/apt/keyrings \ + && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \ + && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ + && apt-get update \ + && apt-get install -y libgd3 php8.2-cli php8.2-dev \ + php8.2-pgsql php8.2-sqlite3 php8.2-gd php8.2-imagick \ + php8.2-curl php8.2-mongodb \ + php8.2-imap php8.2-mysql php8.2-mbstring \ + php8.2-xml php8.2-zip php8.2-bcmath php8.2-soap \ + php8.2-intl php8.2-readline \ + php8.2-ldap \ + php8.2-msgpack php8.2-igbinary php8.2-redis php8.2-swoole \ + php8.2-memcached php8.2-pcov php8.2-xdebug \ + && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y nodejs \ + && npm install -g npm \ + && npm install -g pnpm \ + && npm install -g bun \ + && corepack enable \ + && corepack prepare yarn@stable --activate \ + && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y mysql-client \ + && apt-get install -y postgresql-client-$POSTGRES_VERSION \ + && apt-get -y autoremove \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.2 + +RUN userdel -r ubuntu +RUN groupadd --force -g $WWWGROUP sail +RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail +RUN git config --global --add safe.directory /var/www/html + +COPY start-container /usr/local/bin/start-container +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY php.ini /etc/php/8.2/cli/conf.d/99-sail.ini +RUN chmod +x /usr/local/bin/start-container + +EXPOSE 80/tcp + +ENTRYPOINT ["start-container"] diff --git a/docker/8.2/php.ini b/docker/8.2/php.ini new file mode 100644 index 0000000..0d8ce9e --- /dev/null +++ b/docker/8.2/php.ini @@ -0,0 +1,5 @@ +[PHP] +post_max_size = 100M +upload_max_filesize = 100M +variables_order = EGPCS +pcov.directory = . diff --git a/docker/8.2/start-container b/docker/8.2/start-container new file mode 100644 index 0000000..40c55df --- /dev/null +++ b/docker/8.2/start-container @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then + echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'." + exit 1 +fi + +if [ ! -z "$WWWUSER" ]; then + usermod -u $WWWUSER sail +fi + +if [ ! -d /.composer ]; then + mkdir /.composer +fi + +chmod -R ugo+rw /.composer + +if [ $# -gt 0 ]; then + if [ "$SUPERVISOR_PHP_USER" = "root" ]; then + exec "$@" + else + exec gosu $WWWUSER "$@" + fi +else + exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf +fi diff --git a/docker/8.2/supervisord.conf b/docker/8.2/supervisord.conf new file mode 100644 index 0000000..656da8a --- /dev/null +++ b/docker/8.2/supervisord.conf @@ -0,0 +1,14 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:php] +command=%(ENV_SUPERVISOR_PHP_COMMAND)s +user=%(ENV_SUPERVISOR_PHP_USER)s +environment=LARAVEL_SAIL="1" +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/docker/8.3/Dockerfile b/docker/8.3/Dockerfile new file mode 100644 index 0000000..496a52d --- /dev/null +++ b/docker/8.3/Dockerfile @@ -0,0 +1,74 @@ +FROM ubuntu:24.04 + +LABEL maintainer="Taylor Otwell" + +ARG WWWGROUP +ARG NODE_VERSION=24 +ARG MYSQL_CLIENT="mysql-client" +ARG POSTGRES_VERSION=18 + +WORKDIR /var/www/html + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC +ENV LANG=C.UTF-8 +ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80" +ENV SUPERVISOR_PHP_USER="sail" +ENV PLAYWRIGHT_BROWSERS_PATH=0 + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \ + echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \ + echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom + +RUN apt-get update \ + && mkdir -p /etc/apt/keyrings \ + && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \ + && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ + && apt-get update \ + && apt-get install -y libgd3 php8.3-cli php8.3-dev \ + php8.3-pgsql php8.3-sqlite3 php8.3-gd \ + php8.3-curl php8.3-mongodb \ + php8.3-imap php8.3-mysql php8.3-mbstring \ + php8.3-xml php8.3-zip php8.3-bcmath php8.3-soap \ + php8.3-intl php8.3-readline \ + php8.3-ldap \ + php8.3-msgpack php8.3-igbinary php8.3-redis \ + php8.3-memcached php8.3-pcov php8.3-imagick php8.3-xdebug php8.3-swoole \ + && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y nodejs \ + && npm install -g npm \ + && npm install -g pnpm \ + && npm install -g bun \ + && npx playwright install-deps \ + && corepack enable \ + && corepack prepare yarn@stable --activate \ + && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y $MYSQL_CLIENT \ + && apt-get install -y postgresql-client-$POSTGRES_VERSION \ + && apt-get -y autoremove \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.3 + +RUN userdel -r ubuntu +RUN groupadd --force -g $WWWGROUP sail +RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail +RUN git config --global --add safe.directory /var/www/html + +COPY start-container /usr/local/bin/start-container +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY php.ini /etc/php/8.3/cli/conf.d/99-sail.ini +RUN chmod +x /usr/local/bin/start-container + +EXPOSE 80/tcp + +ENTRYPOINT ["start-container"] diff --git a/docker/8.3/php.ini b/docker/8.3/php.ini new file mode 100644 index 0000000..0d8ce9e --- /dev/null +++ b/docker/8.3/php.ini @@ -0,0 +1,5 @@ +[PHP] +post_max_size = 100M +upload_max_filesize = 100M +variables_order = EGPCS +pcov.directory = . diff --git a/docker/8.3/start-container b/docker/8.3/start-container new file mode 100644 index 0000000..40c55df --- /dev/null +++ b/docker/8.3/start-container @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then + echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'." + exit 1 +fi + +if [ ! -z "$WWWUSER" ]; then + usermod -u $WWWUSER sail +fi + +if [ ! -d /.composer ]; then + mkdir /.composer +fi + +chmod -R ugo+rw /.composer + +if [ $# -gt 0 ]; then + if [ "$SUPERVISOR_PHP_USER" = "root" ]; then + exec "$@" + else + exec gosu $WWWUSER "$@" + fi +else + exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf +fi diff --git a/docker/8.3/supervisord.conf b/docker/8.3/supervisord.conf new file mode 100644 index 0000000..656da8a --- /dev/null +++ b/docker/8.3/supervisord.conf @@ -0,0 +1,14 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:php] +command=%(ENV_SUPERVISOR_PHP_COMMAND)s +user=%(ENV_SUPERVISOR_PHP_USER)s +environment=LARAVEL_SAIL="1" +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/docker/8.4/Dockerfile b/docker/8.4/Dockerfile new file mode 100644 index 0000000..71227ae --- /dev/null +++ b/docker/8.4/Dockerfile @@ -0,0 +1,74 @@ +FROM ubuntu:24.04 + +LABEL maintainer="Taylor Otwell" + +ARG WWWGROUP +ARG NODE_VERSION=24 +ARG MYSQL_CLIENT="mysql-client" +ARG POSTGRES_VERSION=18 + +WORKDIR /var/www/html + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC +ENV LANG=C.UTF-8 +ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80" +ENV SUPERVISOR_PHP_USER="sail" +ENV PLAYWRIGHT_BROWSERS_PATH=0 + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \ + echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \ + echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom + +RUN apt-get update && apt-get upgrade -y \ + && mkdir -p /etc/apt/keyrings \ + && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \ + && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ + && apt-get update \ + && apt-get install -y libgd3 php8.4-cli php8.4-dev \ + php8.4-pgsql php8.4-sqlite3 php8.4-gd \ + php8.4-curl php8.4-mongodb \ + php8.4-imap php8.4-mysql php8.4-mbstring \ + php8.4-xml php8.4-zip php8.4-bcmath php8.4-soap \ + php8.4-intl php8.4-readline \ + php8.4-ldap \ + php8.4-msgpack php8.4-igbinary php8.4-redis php8.4-swoole \ + php8.4-memcached php8.4-pcov php8.4-imagick php8.4-xdebug \ + && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y nodejs \ + && npm install -g npm \ + && npm install -g pnpm \ + && npm install -g bun \ + && npx playwright install-deps \ + && corepack enable \ + && corepack prepare yarn@stable --activate \ + && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y $MYSQL_CLIENT \ + && apt-get install -y postgresql-client-$POSTGRES_VERSION \ + && apt-get -y autoremove \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.4 + +RUN userdel -r ubuntu +RUN groupadd --force -g $WWWGROUP sail +RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail +RUN git config --global --add safe.directory /var/www/html + +COPY start-container /usr/local/bin/start-container +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY php.ini /etc/php/8.4/cli/conf.d/99-sail.ini +RUN chmod +x /usr/local/bin/start-container + +EXPOSE 80/tcp + +ENTRYPOINT ["start-container"] diff --git a/docker/8.4/php.ini b/docker/8.4/php.ini new file mode 100644 index 0000000..0d8ce9e --- /dev/null +++ b/docker/8.4/php.ini @@ -0,0 +1,5 @@ +[PHP] +post_max_size = 100M +upload_max_filesize = 100M +variables_order = EGPCS +pcov.directory = . diff --git a/docker/8.4/start-container b/docker/8.4/start-container new file mode 100644 index 0000000..40c55df --- /dev/null +++ b/docker/8.4/start-container @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then + echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'." + exit 1 +fi + +if [ ! -z "$WWWUSER" ]; then + usermod -u $WWWUSER sail +fi + +if [ ! -d /.composer ]; then + mkdir /.composer +fi + +chmod -R ugo+rw /.composer + +if [ $# -gt 0 ]; then + if [ "$SUPERVISOR_PHP_USER" = "root" ]; then + exec "$@" + else + exec gosu $WWWUSER "$@" + fi +else + exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf +fi diff --git a/docker/8.4/supervisord.conf b/docker/8.4/supervisord.conf new file mode 100644 index 0000000..656da8a --- /dev/null +++ b/docker/8.4/supervisord.conf @@ -0,0 +1,14 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:php] +command=%(ENV_SUPERVISOR_PHP_COMMAND)s +user=%(ENV_SUPERVISOR_PHP_USER)s +environment=LARAVEL_SAIL="1" +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/docker/8.5/Dockerfile b/docker/8.5/Dockerfile new file mode 100644 index 0000000..706fd3e --- /dev/null +++ b/docker/8.5/Dockerfile @@ -0,0 +1,92 @@ +FROM ubuntu:24.04 + +LABEL maintainer="Taylor Otwell" + +ARG WWWGROUP +ARG NODE_VERSION=24 +ARG MYSQL_CLIENT="mysql-client" +ARG POSTGRES_VERSION=18 + +WORKDIR /var/www/html + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC +ENV LANG=C.UTF-8 +ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80" +ENV SUPERVISOR_PHP_USER="sail" +ENV PLAYWRIGHT_BROWSERS_PATH=0 + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \ + echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \ + echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom + +RUN apt-get update && apt-get upgrade -y \ + && mkdir -p /etc/apt/keyrings \ + && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \ + && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ + && apt-get update \ + && apt-get install -y \ + libgd3 \ + php8.5-cli \ + php8.5-dev \ + php8.5-pgsql \ + php8.5-sqlite3 \ + php8.5-gd \ + php8.5-curl \ + php8.5-mongodb \ + php8.5-imap \ + php8.5-mysql \ + php8.5-mbstring \ + php8.5-xml \ + php8.5-zip \ + php8.5-bcmath \ + php8.5-soap \ + php8.5-intl \ + php8.5-readline \ + php8.5-ldap \ + php8.5-msgpack \ + php8.5-igbinary \ + php8.5-redis \ + php8.5-swoole \ + php8.5-memcached \ + php8.5-pcov \ + php8.5-imagick \ + php8.5-xdebug \ + && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y nodejs \ + && npm install -g npm \ + && npm install -g pnpm \ + && npm install -g bun \ + && npx playwright install-deps \ + && corepack enable \ + && corepack prepare yarn@stable --activate \ + && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y $MYSQL_CLIENT \ + && apt-get install -y postgresql-client-$POSTGRES_VERSION \ + && apt-get -y autoremove \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.5 + +RUN userdel -r ubuntu +RUN groupadd --force -g $WWWGROUP sail +RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail +RUN git config --global --add safe.directory /var/www/html + +COPY start-container /usr/local/bin/start-container +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY php.ini /etc/php/8.5/cli/conf.d/99-sail.ini +RUN chmod +x /usr/local/bin/start-container + +EXPOSE 80/tcp + +ENTRYPOINT ["start-container"] diff --git a/docker/8.5/php.ini b/docker/8.5/php.ini new file mode 100644 index 0000000..0d8ce9e --- /dev/null +++ b/docker/8.5/php.ini @@ -0,0 +1,5 @@ +[PHP] +post_max_size = 100M +upload_max_filesize = 100M +variables_order = EGPCS +pcov.directory = . diff --git a/docker/8.5/start-container b/docker/8.5/start-container new file mode 100644 index 0000000..40c55df --- /dev/null +++ b/docker/8.5/start-container @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then + echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'." + exit 1 +fi + +if [ ! -z "$WWWUSER" ]; then + usermod -u $WWWUSER sail +fi + +if [ ! -d /.composer ]; then + mkdir /.composer +fi + +chmod -R ugo+rw /.composer + +if [ $# -gt 0 ]; then + if [ "$SUPERVISOR_PHP_USER" = "root" ]; then + exec "$@" + else + exec gosu $WWWUSER "$@" + fi +else + exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf +fi diff --git a/docker/8.5/supervisord.conf b/docker/8.5/supervisord.conf new file mode 100644 index 0000000..656da8a --- /dev/null +++ b/docker/8.5/supervisord.conf @@ -0,0 +1,14 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:php] +command=%(ENV_SUPERVISOR_PHP_COMMAND)s +user=%(ENV_SUPERVISOR_PHP_USER)s +environment=LARAVEL_SAIL="1" +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/docker/mariadb/create-testing-database.sh b/docker/mariadb/create-testing-database.sh new file mode 100644 index 0000000..4993ba2 --- /dev/null +++ b/docker/mariadb/create-testing-database.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +/usr/bin/mariadb --user=root --password="$MYSQL_ROOT_PASSWORD" <<-EOSQL + CREATE DATABASE IF NOT EXISTS testing; +EOSQL + +if [ -n "$MYSQL_USER" ]; then +/usr/bin/mariadb --user=root --password="$MYSQL_ROOT_PASSWORD" <<-EOSQL + GRANT ALL PRIVILEGES ON \`testing%\`.* TO '$MYSQL_USER'@'%'; +EOSQL +fi diff --git a/docker/mysql/create-testing-database.sh b/docker/mysql/create-testing-database.sh new file mode 100644 index 0000000..666156d --- /dev/null +++ b/docker/mysql/create-testing-database.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +mysql --user=root --password="$MYSQL_ROOT_PASSWORD" <<-EOSQL + CREATE DATABASE IF NOT EXISTS testing; +EOSQL + +if [ -n "$MYSQL_USER" ]; then +mysql --user=root --password="$MYSQL_ROOT_PASSWORD" <<-EOSQL + GRANT ALL PRIVILEGES ON \`testing%\`.* TO '$MYSQL_USER'@'%'; +EOSQL +fi diff --git a/docker/pgsql/create-testing-database.sql b/docker/pgsql/create-testing-database.sql new file mode 100644 index 0000000..d84dc07 --- /dev/null +++ b/docker/pgsql/create-testing-database.sql @@ -0,0 +1,2 @@ +SELECT 'CREATE DATABASE testing' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'testing')\gexec diff --git a/docs/WHITEBOX_TESTING.md b/docs/WHITEBOX_TESTING.md new file mode 100644 index 0000000..584452f --- /dev/null +++ b/docs/WHITEBOX_TESTING.md @@ -0,0 +1,1396 @@ +# Dokumentasi Whitebox Testing + +**Proyek:** Laravel Application Security Audit +**Tanggal:** 16 Mei 2026 +**Metode:** Whitebox Testing (Glass-box / Structural Testing) +**Framework:** Pest PHP v3 + PHPUnit v11, Laravel 13 +**Total Test:** 371 test, 1.182 assertions — **semua LULUS (0 gagal)** + +--- + +## Daftar Isi + +1. [Pendahuluan](#1-pendahuluan) +2. [Strategi dan Metodologi](#2-strategi-dan-metodologi) +3. [Infrastruktur dan Konfigurasi Test](#3-infrastruktur-dan-konfigurasi-test) +4. [Unit Tests](#4-unit-tests) +5. [Feature Tests — Autentikasi](#5-feature-tests--autentikasi) +6. [Feature Tests — API](#6-feature-tests--api) +7. [Feature Tests — Middleware dan Keamanan](#7-feature-tests--middleware-dan-keamanan) +8. [Feature Tests — Access Control](#8-feature-tests--access-control) +9. [Feature Tests — Model dan Database](#9-feature-tests--model-dan-database) +10. [Feature Tests — Fitur Sistem](#10-feature-tests--fitur-sistem) +11. [Feature Tests — Services](#11-feature-tests--services) +12. [Feature Tests — Performa](#12-feature-tests--performa) +13. [Temuan Keamanan dan Perbaikan](#13-temuan-keamanan-dan-perbaikan) +14. [Statistik dan Cakupan](#14-statistik-dan-cakupan) +15. [Cara Menjalankan Test](#15-cara-menjalankan-test) + +--- + +## 1. Pendahuluan + +### 1.1 Apa itu Whitebox Testing? + +Whitebox testing (disebut juga glass-box testing atau structural testing) adalah metode pengujian perangkat lunak di mana penguji memiliki akses penuh ke kode sumber, arsitektur internal, alur logika, dan struktur data aplikasi. Berbeda dengan blackbox testing yang hanya menguji perilaku dari luar, whitebox testing menguji **bagaimana** suatu sistem bekerja dari dalam. + +Dalam konteks proyek ini, whitebox testing dilakukan dengan: + +- Membaca seluruh kode sumber controller, middleware, model, dan service +- Mengidentifikasi setiap jalur eksekusi (code path) yang mungkin terjadi +- Menulis test case yang secara eksplisit menelusuri jalur-jalur tersebut +- Memverifikasi bahwa setiap kondisi batas (boundary condition) ditangani dengan benar +- Memastikan bahwa mekanisme keamanan aktif di setiap lapisan yang relevan + +### 1.2 Tujuan dan Ruang Lingkup + +Testing ini dilakukan dalam konteks **security audit 3 gelombang** yang telah dilakukan pada aplikasi. Tujuan spesifiknya adalah: + +| Tujuan | Keterangan | +|--------|------------| +| Verifikasi Session Fixation | Memastikan session ID diregenerasi setelah setiap otentikasi | +| Verifikasi Password Policy | Memastikan riwayat, expiry, dan aturan kuat berjalan | +| Verifikasi Prunable Models | Memastikan data lama otomatis terhapus sesuai policy | +| Verifikasi Access Control | Memastikan setiap endpoint dilindungi oleh permission yang tepat | +| Verifikasi Rate Limiting | Memastikan brute-force dilindungi di semua endpoint sensitif | +| Verifikasi Security Headers | Memastikan header HTTP keamanan terpasang di semua respons | +| Verifikasi XSS Prevention | Memastikan output di-escape sebelum dikirim ke client | +| Verifikasi Cascade Integrity | Memastikan penghapusan data tidak menimbulkan orphan records | + +### 1.3 Asumsi Dasar + +- Seluruh test dijalankan dalam **lingkungan terisolasi** (Docker container, database PostgreSQL terpisah) +- Database di-refresh setiap test menggunakan `RefreshDatabase` trait +- Driver session, cache, dan queue semuanya menggunakan `array` (in-memory) agar test deterministik +- Test tidak menyentuh sistem produksi, file system nyata (kecuali `Storage::fake()`), atau layanan eksternal + +--- + +## 2. Strategi dan Metodologi + +### 2.1 Pendekatan Pengujian + +Whitebox testing pada proyek ini mengikuti pendekatan berlapis: + +``` +┌─────────────────────────────────────────────────────┐ +│ UNIT TESTS │ +│ Menguji fungsi murni tanpa container Laravel │ +│ (formatter, helper, caster, exception factory) │ +├─────────────────────────────────────────────────────┤ +│ FEATURE TESTS │ +│ Menguji alur end-to-end dengan container penuh │ +│ (HTTP request → middleware → controller → DB) │ +└─────────────────────────────────────────────────────┘ +``` + +### 2.2 Pola Test yang Digunakan + +**Happy Path Testing** — jalur sukses normal: +```php +test('password update succeeds with valid current password', function () { + $user = User::factory()->create(['password' => Hash::make('current-pass')]); + $this->actingAs($user)->put('/password', [...valid data...])->assertRedirect(); + expect(Hash::check('New-Pass-123', $user->fresh()->password))->toBeTrue(); +}); +``` + +**Negative Testing** — pengujian kondisi kegagalan yang diharapkan: +```php +test('wrong current password is rejected', function () { + // Memastikan sistem menolak password yang salah + $this->actingAs($user)->put('/password', ['current_password' => 'wrong']) + ->assertSessionHasErrors(); +}); +``` + +**Boundary Testing** — pengujian nilai batas: +```php +test('upload rejects image exceeding 5 MB', function () { + $file = UploadedFile::fake()->create('big.jpg', 6000, 'image/jpeg'); // 6 MB + $this->actingAs($user)->postJson('/editor/upload', ['upload' => $file])->assertStatus(422); +}); +``` + +**Security Path Testing** — pengujian jalur keamanan spesifik: +```php +test('web login regenerates the session id', function () { + $before = session()->getId(); + $this->post('/login', [...]); + expect(session()->getId())->not->toBe($before); // harus berbeda +}); +``` + +### 2.3 Isolasi Test + +Setiap test menggunakan `RefreshDatabase` yang otomatis diterapkan melalui `tests/Pest.php`. Middleware yang bisa mengganggu test seperti `ThrottleRequests` dan `CheckLegalAgreement` dinonaktifkan secara global, kecuali di test yang memang menguji fitur tersebut. + +--- + +## 3. Infrastruktur dan Konfigurasi Test + +### 3.1 File Konfigurasi: `phpunit.xml` + +```xml + + + + + + + + + +``` + +> **Catatan kritis:** Atribut `force="true"` wajib ada agar nilai ini menimpa konfigurasi di `.env` production yang menggunakan Redis. Tanpa `force="true"`, test akan mencoba konek ke Redis dan gagal karena hostname `redis` tidak dapat di-resolve di luar container Docker. + +### 3.2 File Bootstrap: `tests/Pest.php` + +```php +uses(TestCase::class, RefreshDatabase::class) + ->in('Feature'); // Semua Feature test refresh DB + +uses(TestCase::class) + ->in('Unit'); // Unit test tidak butuh DB + +expect()->extend('toBeOne', fn () => $this->toBe(1)); + +// Nonaktifkan throttle dan legal check secara global +\Illuminate\Support\Facades\Route::middlewareGroup('web', [...]); +``` + +Dua middleware dinonaktifkan global: +- `ThrottleRequests` — dinonaktifkan agar test tidak saling mengganggu; ada file test khusus yang mengaktifkan kembali +- `CheckLegalAgreement` — dinonaktifkan agar sebagian besar test tidak perlu setup persetujuan hukum + +### 3.3 Struktur Direktori Test + +``` +tests/ +├── Pest.php ← Bootstrap + trait global +├── TestCase.php ← Base class +├── Unit/ +│ ├── ExampleTest.php +│ ├── Exceptions/ +│ │ └── CustomExceptionsTest.php +│ ├── Helpers/ +│ │ └── SessionHelperTest.php +│ ├── Monitoring/ +│ │ └── MonitoringFormatterTest.php +│ ├── System/ +│ │ └── ActivityFormatterTest.php +│ └── SystemConfig/ +│ └── SettingValueCasterTest.php +└── Feature/ + ├── AccessControl/ + │ ├── PermissionManagementTest.php + │ ├── RoleManagementTest.php + │ └── UserManagementTest.php + ├── Api/ + │ ├── ApiAuthExtendedTest.php + │ ├── AuthTest.php + │ ├── DeviceTokenTest.php + │ ├── HealthTest.php + │ └── OtpTest.php + ├── Auth/ + │ ├── AuthenticationTest.php + │ ├── EmailVerificationTest.php + │ ├── PasswordConfirmationTest.php + │ ├── PasswordControllerTest.php ← BARU (whitebox) + │ ├── PasswordResetTest.php + │ ├── PasswordUpdateTest.php + │ ├── RegistrationTest.php + │ ├── SessionFixationTest.php ← BARU (whitebox) + │ ├── SocialAuthTest.php + │ ├── TwoFactorTest.php + │ └── WebAuthnConfigTest.php + ├── Database/ + │ └── CascadeIntegrityTest.php + ├── Helpers/ + │ ├── ApiResponseTest.php + │ └── PasswordRuleHelperTest.php + ├── Middleware/ + │ ├── CheckActivePermissionTest.php + │ ├── CheckLegalAgreementTest.php + │ ├── IpAccessControlTest.php + │ ├── PasswordExpiryMiddlewareTest.php + │ └── SecurityHeadersTest.php + ├── Models/ + │ └── PrunableModelsTest.php ← BARU (whitebox) + ├── Performance/ + │ └── NPlusOneTest.php + ├── Services/ + │ ├── Auth/ + │ │ └── PasswordPolicyServiceTest.php + │ ├── System/ + │ │ └── BackupManagementServiceTest.php + │ └── SystemConfig/ + │ └── SystemConfigServiceTest.php + ├── System/ + │ ├── AiCircuitBreakerTest.php + │ ├── EditorUploadTest.php ← BARU (whitebox) + │ ├── NotificationCenterTest.php ← BARU (whitebox) + │ └── SessionManagerTest.php ← BARU (whitebox) + ├── ExampleTest.php + ├── ImpersonateTest.php + ├── MobileConfigTest.php + ├── ProfileTest.php + └── RateLimitTest.php +``` + +--- + +## 4. Unit Tests + +Unit tests menguji komponen secara terisolasi tanpa men-boot container Laravel. Semua fungsi yang diuji adalah **pure functions** atau kelas yang tidak bergantung pada database. + +### 4.1 `SessionHelperTest.php` — Parser User-Agent + +**File:** `tests/Unit/Helpers/SessionHelperTest.php` +**Target:** `app/Helpers/SessionHelper.php` — `parseUserAgent()` + +Fungsi ini digunakan di Session Manager untuk menampilkan informasi perangkat pengguna. Pengujian memverifikasi akurasi deteksi sistem operasi dan browser. + +| Test Case | Input | Expected Output | +|-----------|-------|-----------------| +| null user agent | `null` | `"Unknown"` | +| string kosong | `""` | `"Unknown"` | +| Deteksi Android | `"...Android..."` | Platform: Android | +| Deteksi iOS | `"...iPhone..."` | Platform: iOS | +| Deteksi Windows | `"...Windows NT..."` | Platform: Windows | +| Deteksi macOS | `"...Macintosh..."` | Platform: macOS | +| Deteksi Linux | `"...Linux..."` | Platform: Linux | +| Edge sebelum Chrome | `"...Edg/..."` | Browser: Edge (bukan Chrome) | +| Chrome | `"...Chrome/..."` | Browser: Chrome | +| Firefox | `"...Firefox/..."` | Browser: Firefox | +| Safari | `"...Safari/..." (tanpa Chrome)` | Browser: Safari | +| Browser tidak dikenal | `"BotAgent/1.0"` | Icon: `bi-globe` | + +**Mengapa penting:** Deteksi Edge harus diperiksa **sebelum** Chrome karena user-agent Edge mengandung string "Chrome". Tanpa urutan yang benar, semua pengguna Edge akan terdeteksi sebagai Chrome. + +### 4.2 `SettingValueCasterTest.php` — Casting Nilai Konfigurasi + +**File:** `tests/Unit/SystemConfig/SettingValueCasterTest.php` +**Target:** `app/Services/SystemConfig/SettingValueCaster.php` + +Menguji serialisasi dan deserialisasi nilai pengaturan sistem untuk berbagai tipe data. + +**Tipe yang diuji:** + +| Tipe | Input | Setelah `normalize()` | Setelah `serialize()` | Setelah `deserialize()` | +|------|----|---|---|---| +| `bool` | `"true"` | `true` | `"1"` | `true` | +| `int` | `"42"` | `42` | `"42"` | `42` | +| `float` | `"3.14"` | `3.14` | `"3.14"` | `3.14` | +| `json` | `'{"a":1}'` | `["a" => 1]` | `'{"a":1}'` | `["a" => 1]` | +| `string` | `" hello "` | `"hello"` | `"hello"` | `"hello"` | +| `image_path` | `/storage/img.png` | path | path | path | + +Test `isUnchanged()` memverifikasi bahwa nilai yang identik dengan yang tersimpan tidak dianggap berubah, mencegah penulisan revision yang tidak perlu. + +### 4.3 `ActivityFormatterTest.php` — Format Log Aktivitas + +**File:** `tests/Unit/System/ActivityFormatterTest.php` +**Target:** `app/Services/System/ActivityFormatter.php` + +Menguji logika format tampilan di audit log. + +**Test `getFriendlyModelName()`:** +- `null` → `"System"` +- `"App\Models\User"` → `"User"` +- Class tidak dikenal → headline dari basename + +**Test `getEventBadgeClass()`:** + +| Event | CSS Class | +|-------|-----------| +| `created` | `text-bg-success` | +| `updated` | `text-bg-warning` | +| `deleted` | `text-bg-danger` | +| `restored` | `text-bg-info` | +| `login` | `text-bg-info` | +| `logout` | `text-bg-secondary` | +| `password_changed` | `text-bg-primary` | +| event tidak dikenal | `text-bg-theme-1` | + +Test juga memverifikasi bahwa fungsi **case-insensitive** (input `"LOGIN"` sama dengan `"login"`). + +**Test `formatChanges()`:** +- Field sensitif (`password`, `remember_token`) disembunyikan dari output +- Nilai boolean ditampilkan sebagai `"Ya"` / `"Tidak"` dalam bahasa Indonesia +- Array nested diubah menjadi string yang dapat dibaca manusia + +### 4.4 `CustomExceptionsTest.php` — Custom Exception + +**File:** `tests/Unit/Exceptions/CustomExceptionsTest.php` +**Target:** `app/Exceptions/` + +Memverifikasi bahwa factory method pada custom exception menghasilkan pesan yang terstruktur dan jenis exception yang benar. + +| Exception Class | Factory Methods yang Diuji | +|-----------------|---------------------------| +| `SystemConfigException` | `invalidKey()`, `saveFailed()`, `cacheFailed()` | +| `BackupOperationException` | `notFound()`, `restoreFailed()`, `createFailed()` | +| `MonitoringException` | `serviceUnavailable()`, `dataCollectionFailed()` | + +Semua exception memverifikasi bahwa mereka adalah turunan `RuntimeException`. + +### 4.5 `MonitoringFormatterTest.php` — Format Monitoring + +**File:** `tests/Unit/Monitoring/MonitoringFormatterTest.php` +**Target:** `app/Services/Monitoring/MonitoringFormatter.php` + +| Fungsi | Test Cases | +|--------|-----------| +| `bytes()` | B, KB, MB, GB, TB; presisi; nilai negatif di-clamp ke 0 | +| `duration()` | < 1 menit, format menit, jam+menit, hari+jam+menit; lewati komponen nol | +| `parseBytes()` | Round-trip unit penuh, string numerik, unit tidak dikenal, nilai fraksional | + +--- + +## 5. Feature Tests — Autentikasi + +### 5.1 `AuthenticationTest.php` — Login/Logout Dasar + +**File:** `tests/Feature/Auth/AuthenticationTest.php` + +| Test Case | Verifikasi | +|-----------|-----------| +| Halaman login tersedia | HTTP 200, tampilan form login | +| Login berhasil dengan kredensial valid | Redirect ke dashboard, user terautentikasi | +| Login gagal dengan password salah | Session error, user tidak terautentikasi | +| Logout berhasil | Session dihancurkan, redirect ke login | + +### 5.2 `PasswordControllerTest.php` — Update Password Web (BARU) + +**File:** `tests/Feature/Auth/PasswordControllerTest.php` +**Target:** `app/Http/Controllers/Auth/PasswordController.php` +**Endpoint:** `PUT /password` + +File ini ditulis khusus sebagai bagian dari whitebox testing untuk menguji semua jalur kode di `PasswordController::update()`. + +#### Test 1: Happy Path +``` +Input: current_password benar, password baru valid, konfirmasi cocok +Expected: Redirect ke /profile, tanpa error di session +Verifikasi: Hash::check(password_baru, user->fresh()->password) === true +``` + +#### Test 2: Stamping Timestamp +``` +Setup: password_changed_at = null +Input: password update berhasil +Verifikasi: password_changed_at TIDAK NULL setelah update +``` +Memastikan `PasswordPolicyService::recordPasswordChange()` dipanggil dan menyimpan timestamp. + +#### Test 3: Validasi — Password Lama Salah +``` +Input: current_password = 'wrong-pass' +Expected: Session error di bag 'updatePassword', field 'current_password' +Verifikasi: Password di database TIDAK berubah +``` + +#### Test 4: Validasi — Konfirmasi Tidak Cocok +``` +Input: password = 'ABC', password_confirmation = 'XYZ' +Expected: Session errors (validasi gagal) +``` + +#### Test 5: Penolakan Riwayat Password +``` +Setup: password_history_count = 3, user punya riwayat 'OldPassword1!' +Input: Mencoba menggunakan kembali 'OldPassword1!' +Expected: Session error (password sudah pernah digunakan) +``` + +#### Test 6: Bypass Riwayat Jika Dinonaktifkan +``` +Setup: password_history_count = 0 +Input: Menggunakan kembali password lama +Expected: Berhasil (history check di-skip) +``` +Membuktikan bahwa pengecekan riwayat bersyarat, bukan selalu aktif. + +#### Test 7: Pencatatan Riwayat +``` +Setup: password_history_count = 5 +Input: Password update berhasil +Verifikasi: PasswordHistory::where('user_id', $user->id)->count() === 1 +``` + +#### Test 8: Password Lama Tidak Bisa Login +``` +Input: Update password dari 'old-pass-456' ke 'Brand-New-888!' +Verifikasi: + Hash::check('old-pass-456', user->fresh()->password) === false + Hash::check('Brand-New-888!', user->fresh()->password) === true +``` +Test ini menggantikan uji `logoutOtherDevices` yang lebih kompleks — fokus pada hasil fungsional yang paling penting: password lama benar-benar tidak valid. + +#### Test 9: Guest Ditolak +``` +Input: PUT /password tanpa autentikasi +Expected: Redirect ke /login (HTTP 302) +``` + +#### Test 10: Respons JSON +``` +Input: putJson('/password', [...]) dengan credentials valid +Expected: HTTP 200, JSON body {success: true} +``` +Membuktikan controller menangani JSON request dengan benar. + +### 5.3 `SessionFixationTest.php` — Pencegahan Session Fixation (BARU) + +**File:** `tests/Feature/Auth/SessionFixationTest.php` + +Session fixation adalah serangan di mana penyerang menanamkan ID session sebelum korban login, lalu menggunakan ID yang sama setelah korban login untuk membajak session. Pencegahan dilakukan dengan **meregenerasi session ID** setelah setiap otentikasi. + +#### Test 1: Login Web +``` +Sebelum: session_id = "abc123" +Action: POST /login dengan kredensial valid +Setelah: session_id ≠ "abc123" +``` + +#### Test 2: Verifikasi 2FA +``` +Setup: Session berisi 2fa_user_id, 2fa_code, 2fa_expires_at +Sebelum: session_id = X +Action: POST /2fa dengan code benar +Setelah: session_id ≠ X +``` +Penting: tanpa regenerasi ini, penyerang yang mengetahui session pre-2FA bisa membypass 2FA. + +#### Test 3: OAuth Callback (SocialAuth) +``` +Setup: feature_google_oauth = true, session berisi social_auth_provider +Action: GET /auth/callback (Socialite di-mock mengembalikan user valid) +Verifikasi: session_id berubah +``` +Perbaikan ini ditambahkan ke `SocialAuthController::callback()` selama security audit. + +#### Test 4: Reset Password +``` +Action: POST /reset-password dengan token valid +Verifikasi: session_id berubah setelah reset +``` + +#### Test 5: Mulai Impersonasi +``` +Action: Admin memulai impersonasi user lain +Verifikasi: session_id berubah (session admin terisolasi dari session target) +``` + +#### Test 6: Berhenti Impersonasi +``` +Action: POST /impersonate/stop +Verifikasi: session_id berubah (kembali ke konteks admin) +``` + +### 5.4 `SocialAuthTest.php` — OAuth Authentication + +**File:** `tests/Feature/Auth/SocialAuthTest.php` +**Target:** `app/Http/Controllers/Auth/SocialAuthController.php` + +| Test Case | Skenario Keamanan | Expected | +|-----------|------------------|---------| +| Provider dinonaktifkan | `feature_google_oauth = false` | 404 Not Found | +| Provider diaktifkan | `feature_google_oauth = true` | Redirect ke Google | +| Tanpa sesi provider | Tidak ada `social_auth_provider` di session | Redirect ke login + error | +| Email belum diverifikasi | `email_verified = false` dari provider | Ditolak, tidak login | +| User baru | Email belum ada di DB | User baru dibuat, diberi role User | +| Link email | User ada, belum punya google_id | `google_id` terhubung | +| **Takeover dicegah** | Email ada, google_id BERBEDA | **Ditolak** — identity existing user tidak bisa ditimpa | +| Re-use by provider ID | User sudah punya google_id yang cocok | Login langsung | +| Exception dari Socialite | `driver->user()` throw exception | Redirect ke login + error | + +**Kasus kritis — Identity Takeover Prevention:** +```php +// Penyerang punya email yang sama dengan user lain, tapi google_id berbeda +$existing = User::factory()->create([ + 'email' => 'taken@example.com', + 'google_id' => 'different-google-id', // sudah ada +]); +// Socialite mengembalikan: id='attacker-id', email='taken@example.com' +// Sistem HARUS menolak — tidak boleh menimpa google_id yang sudah ada +$this->get('/auth/callback') + ->assertRedirect('/login') + ->assertSessionHas('error'); +expect($existing->fresh()->google_id)->toBe('different-google-id'); // tidak berubah +``` + +### 5.5 `TwoFactorTest.php` — Autentikasi Dua Faktor + +**File:** `tests/Feature/Auth/TwoFactorTest.php` +**Target:** 2FA Controller + +| Test Case | Verifikasi | +|-----------|-----------| +| View 2FA butuh session | Tanpa `auth.2fa_user_id` → redirect | +| Kode benar → login | User terautentikasi setelah kode cocok | +| Kode salah → ditolak | User tetap tidak terautentikasi | +| Kode kadaluarsa → redirect | Diarahkan kembali ke login | +| Kode terlalu pendek | Validasi menolak (< 6 digit) | +| Trust device: simpan | Row di `user_trusted_devices` terbuat | +| Trust device: tidak dipilih | Tidak ada row trusted device | +| Cookie device trusted | Skip 2FA, langsung login | +| Cookie device salah secret | 2FA tetap diminta | + +### 5.6 `PasswordResetTest.php` — Reset Password via Email + +**File:** `tests/Feature/Auth/PasswordResetTest.php` + +| Test Case | Verifikasi | +|-----------|-----------| +| Halaman request reset tersedia | HTTP 200 | +| Request token berhasil | Email dikirim (Notification::fake) | +| Reset dengan token valid | Password diubah, redirect ke login | + +### 5.7 `RegistrationTest.php` — Registrasi User + +**File:** `tests/Feature/Auth/RegistrationTest.php` + +| Test Case | Verifikasi | +|-----------|-----------| +| Halaman registrasi tersedia | HTTP 200 | +| Registrasi berhasil | User dibuat di DB, role `User` diberikan | + +### 5.8 `WebAuthnConfigTest.php` — Konfigurasi WebAuthn + +**File:** `tests/Feature/Auth/WebAuthnConfigTest.php` + +Test struktural yang memverifikasi: +- Class controller WebAuthn login dan register ada +- Method yang dibutuhkan tersedia +- Setting `webauthn_enabled` dapat di-toggle +- Tabel `webauthn_credentials` ada dan memiliki kolom yang benar + +--- + +## 6. Feature Tests — API + +### 6.1 `AuthTest.php` — API Authentication Dasar + +**File:** `tests/Feature/Api/AuthTest.php` +**Base URL:** `/api/v1/` + +| Endpoint | Test Case | Kode Respons | +|----------|-----------|-------------| +| `POST /register` | Registrasi sukses | 201 Created | +| `POST /register` | Email duplikat | 422 Unprocessable | +| `POST /login` | Credentials valid | 200 OK + token | +| `POST /login` | Password salah | 422 Unprocessable | +| `POST /login` | User tidak aktif | 422 Unprocessable | +| `POST /logout` | Logout dengan token | 200 OK | +| `GET /profile` | Profil user sendiri | 200 OK + data user | + +### 6.2 `ApiAuthExtendedTest.php` — API Auth Lanjutan (BARU) + +**File:** `tests/Feature/Api/ApiAuthExtendedTest.php` + +File ini ditulis sebagai bagian whitebox testing untuk menguji endpoint yang lebih kompleks dari API AuthController. + +#### Grup: Delete Account (`DELETE /api/v1/profile/delete`) + +| Test Case | Input | Expected | +|-----------|-------|---------| +| Hapus akun berhasil | password benar | 200, user terhapus dari DB | +| Password salah | password salah | 422, user tetap ada | +| Password tidak dikirim | body kosong | 422 Validation Error | +| Guest tidak bisa akses | tanpa token | 401 Unauthorized | +| Semua token ikut dicabut | delete akun | `user->tokens()->count() === 0` | + +**Verifikasi pencabutan token:** +```php +$user->createToken('device-a'); +$user->createToken('device-b'); +$token = $user->createToken('device-c')->plainTextToken; +$this->withHeader('Authorization', "Bearer {$token}") + ->deleteJson('/api/v1/profile/delete', ['password' => 'secret']); +expect($user->tokens()->count())->toBe(0); // semua token dicabut +``` + +#### Grup: Update Password API (`POST /api/v1/profile/password`) + +| Test Case | Verifikasi | +|-----------|-----------| +| Update berhasil | Hash::check(password_baru) === true | +| Current password salah | 422 | +| Reuse password dari riwayat | 422, body: `{status: "error"}` | +| Update berhasil → catat riwayat | `PasswordHistory::count() === 1` | + +#### Grup: Register dengan Password Policy + +| Test Case | Setting | Expected | +|-----------|---------|---------| +| Min length tidak terpenuhi | `password_min_length = 12`, kirim 5 char | 422 | +| Min length terpenuhi | `password_min_length = 6`, kirim 10 char | 201 | +| Harus ada angka | `password_require_numeric = true` | 422 jika tanpa angka | + +#### Grup: Update Profil (`POST /api/v1/profile/update`) + +| Test Case | Verifikasi | +|-----------|-----------| +| Update nama berhasil | `user->fresh()->name === 'Updated Name'` | +| Nama > 255 karakter | 422 Validation Error | + +### 6.3 `OtpTest.php` — OTP Code + +**File:** `tests/Feature/Api/OtpTest.php` + +| Test Case | Verifikasi | +|-----------|-----------| +| Kirim OTP (email valid) | Mail diantri, response 200 | +| Verifikasi OTP salah | 422 | +| Kode OTP < 6 digit | 422 Validation Error | + +### 6.4 `HealthTest.php` — Health Check + +**File:** `tests/Feature/Api/HealthTest.php` + +| Test Case | Verifikasi | +|-----------|-----------| +| GET /api/v1/health | 200 OK, struktur respons valid | +| Respons berisi timestamp | `created_at` ada di body | +| Setiap check punya status | Array `checks` dengan field `status` | + +### 6.5 `DeviceTokenTest.php` — Push Notification Token + +**File:** `tests/Feature/Api/DeviceTokenTest.php` + +| Test Case | Verifikasi | +|-----------|-----------| +| Guest tidak bisa registrasi | 401 Unauthorized | +| User bisa registrasi token | 200, token tersimpan | +| Token duplikat → upsert | Tidak ada error, tidak ada duplikat | +| Unregister token | Token terhapus dari DB | + +--- + +## 7. Feature Tests — Middleware dan Keamanan + +### 7.1 `SecurityHeadersTest.php` — HTTP Security Headers + +**File:** `tests/Feature/Middleware/SecurityHeadersTest.php` + +Setiap request web harus menghasilkan header keamanan yang benar. Test ini membuat route khusus `/__sec-probe` dan memeriksa semua header respons. + +| Header | Nilai yang Diharapkan | Tujuan | +|--------|-----------------------|--------| +| `X-Content-Type-Options` | `nosniff` | Mencegah MIME sniffing | +| `X-Frame-Options` | `SAMEORIGIN` | Mencegah clickjacking | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | Batasi referrer info | +| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | Batasi akses API browser | +| `X-XSS-Protection` | tidak null | Perlindungan XSS browser lama | +| `Strict-Transport-Security` | **null** (over HTTP) | HSTS hanya aktif di HTTPS | + +**Catatan penting:** HSTS **sengaja tidak dikirim** saat request melalui HTTP biasa (seperti dalam environment test). Ini adalah perilaku yang benar — HSTS di HTTP tidak ada gunanya dan bisa menyebabkan masalah jika dikonfigurasi salah. + +### 7.2 `IpAccessControlTest.php` — Kontrol Akses IP + +**File:** `tests/Feature/Middleware/IpAccessControlTest.php` +**Target:** `app/Http/Middleware/IpAccessControl.php` + +| Test Case | Setting | IP Request | Expected | +|-----------|---------|-----------|---------| +| Tanpa aturan | - | 127.0.0.1 | 200 OK | +| IP di blacklist | `ip_blacklist = "127.0.0.1"` | 127.0.0.1 | 403 Forbidden | +| IP tidak di blacklist | `ip_blacklist = "10.0.0.5"` | 127.0.0.1 | 200 OK | +| Admin route, IP tidak di whitelist | `ip_whitelist_admin = "203.0.113.1"` | 127.0.0.1 | 403 Forbidden | +| Admin route, IP di whitelist | `ip_whitelist_admin = "127.0.0.1"` | 127.0.0.1 | 200 OK | +| Non-admin route, whitelist admin ada | `ip_whitelist_admin = "203.0.113.1"` | 127.0.0.1 | 200 OK | +| IP di auto-block cache | `ip_block:127.0.0.1` di cache | 127.0.0.1 | 429 | +| Single session: stale session | `session_single_session = true`, `last_session_id` berbeda | - | Redirect ke login | + +**Detail test single session:** +```php +$user = User::factory()->create(['last_session_id' => 'OTHER_SESSION_ID']); +$this->actingAs($user)->get('/__ip-probe') + ->assertRedirect(route('login', absolute: false)); +$this->assertGuest(); // user sudah di-logout +``` + +### 7.3 `RateLimitTest.php` — Rate Limiting + +**File:** `tests/Feature/RateLimitTest.php` + +> **Catatan penting:** Middleware `ThrottleRequests` dinonaktifkan secara global di `Pest.php`. File ini **secara eksplisit mengaktifkan kembali** middleware tersebut dengan `$this->withMiddleware(ThrottleRequests::class)`. + +| Endpoint | Test | Kondisi Block | +|----------|------|--------------| +| `POST /api/v1/login` | 15 percobaan | Salah satu harus menghasilkan 429 | +| `POST /api/v1/forgot-password` | 5 percobaan | Request ke-6 → 429 | +| `POST /api/v1/otp/send` | 5 percobaan | Request ke-6 → 429 | +| `POST /api/v1/register` | 5 percobaan | Request ke-6 → 429 | +| `POST /2fa` | 5 percobaan | Request ke-6 → 429 | + +**Test isolasi IP:** +```php +// IP A sudah memenuhi batas +for ($i = 0; $i < 5; $i++) { + $this->call('POST', '/api/v1/forgot-password', server: ['REMOTE_ADDR' => '10.0.0.1']); +} +// IP B berbeda — tidak boleh kena blok +$r = $this->call('POST', '/api/v1/forgot-password', server: ['REMOTE_ADDR' => '10.0.0.2']); +expect($r->getStatusCode())->not->toBe(429); +``` + +### 7.4 `CheckActivePermissionTest.php` — Permission Aktif + +**File:** `tests/Feature/Middleware/CheckActivePermissionTest.php` + +Middleware ini memverifikasi bahwa permission yang dimiliki user masih dalam status `is_active = true`. Digunakan sebagai lapisan kontrol tambahan di luar Spatie Permission. + +| Test Case | Status Permission | Expected | +|-----------|-----------------|---------| +| Permission tidak aktif | `is_active = false` | 403 Forbidden | +| Permission aktif | `is_active = true` | 200 OK | +| Permission tidak ada | - | 403 Forbidden | +| Status dicache | - | Hasil dicache, tidak query ulang | + +### 7.5 `CheckLegalAgreementTest.php` — Persetujuan Hukum + +**File:** `tests/Feature/Middleware/CheckLegalAgreementTest.php` + +| Test Case | Kondisi | Expected | +|-----------|---------|---------| +| Guest | Tidak terautentikasi | Tidak terpengaruh (lewat) | +| User tanpa consent | Belum pernah setuju | Redirect ke halaman consent | +| User dengan consent terkini | Sudah setuju versi terbaru | 200 OK | +| User dengan consent lama | Versi policy sudah diupdate | Redirect ke consent baru | + +### 7.6 `PasswordExpiryMiddlewareTest.php` — Kadaluarsa Password + +**File:** `tests/Feature/Middleware/PasswordExpiryMiddlewareTest.php` + +| Test Case | Kondisi | Expected | +|-----------|---------|---------| +| Password baru | `password_changed_at` baru | 200 OK, tidak redirect | +| Password kadaluarsa | `password_changed_at` > batas | Redirect ke halaman update password | +| Fitur dinonaktifkan | `password_expiry_days = 0` | Tidak pernah redirect | +| Guest | Tidak terautentikasi | Tidak terpengaruh | + +--- + +## 8. Feature Tests — Access Control + +### 8.1 `UserManagementTest.php` — Manajemen User + +**File:** `tests/Feature/AccessControl/UserManagementTest.php` + +| Test Case | Verifikasi | +|-----------|-----------| +| Guest tidak bisa akses | Redirect ke login | +| User tanpa izin diblokir | 403 Forbidden | +| Buat user berhasil | User ada di DB dengan role | +| Password user baru divalidasi | Aturan kuat diterapkan | +| Update nama | Nama berubah | +| Update role | Role berubah | +| Update password saja | Hanya password yang berubah | +| Toggle status aktif | `is_active` berpindah true/false | +| Soft delete | User ditandai deleted_at | +| Force delete | User dihapus permanen | +| Force delete dengan referensi | Tidak bisa jika masih ada FK aktif (sesuai aturan bisnis) | + +### 8.2 `RoleManagementTest.php` — Manajemen Role + +**File:** `tests/Feature/AccessControl/RoleManagementTest.php` + +| Test Case | Verifikasi | +|-----------|-----------| +| Buat role + permissions | Role dan relasi tersimpan | +| Nama duplikat ditolak | 422 Validation Error | +| Permission invalid | 422 | +| Karakter tidak valid di nama | 422 | +| Update role | Nama dan permissions berubah | +| Toggle status | `is_active` berubah | +| Soft delete (ada user di role) | Ditolak dengan pesan error | +| Hard delete (tidak ada user) | Role dihapus | +| Restore soft-deleted | Role kembali aktif | + +### 8.3 `PermissionManagementTest.php` — Manajemen Permission + +**File:** `tests/Feature/AccessControl/PermissionManagementTest.php` + +| Test Case | Verifikasi | +|-----------|-----------| +| Buat permission | Tersimpan di DB | +| Cross-guard support | Permission bisa untuk guard berbeda | +| Nama duplikat dalam guard | 422 | +| Guard tidak valid | 422 | +| Karakter tidak valid | 422 | +| Update nama | Nama berubah | +| Toggle status | `is_active` berubah | +| Soft delete | `deleted_at` diset | + +--- + +## 9. Feature Tests — Model dan Database + +### 9.1 `PrunableModelsTest.php` — Prunable Models (BARU) + +**File:** `tests/Feature/Models/PrunableModelsTest.php` + +Trait `MassPrunable` dari Laravel memungkinkan model mendefinisikan metode `prunable()` yang mengembalikan query untuk record yang harus dihapus saat `php artisan model:prune` dijalankan. Test ini memverifikasi bahwa setiap model memprune record yang tepat. + +> **Catatan teknis:** File ini awalnya ada di `tests/Unit/Models/` tetapi dipindahkan ke `tests/Feature/Models/` karena query Eloquent membutuhkan database dan container Laravel penuh. + +| Model | Tabel | Kriteria Prune | Diuji | +|-------|-------|----------------|-------| +| `OtpCode` | `otp_codes` | `expires_at < now()` | Record kadaluarsa dipilih, valid tidak dipilih | +| `UserTrustedDevice` | `user_trusted_devices` | `expires_at < now()` | Device expired dipilih | +| `PasswordHistory` | `password_histories` | `created_at < 365 hari lalu` | Record > 1 tahun dipilih | +| `MobileSyncLog` | `mobile_sync_logs` | `synced_at < 30 hari lalu` | Record > 30 hari dipilih | +| `MobileErrorLog` | `mobile_error_logs` | `occurred_at < 90 hari lalu` | Record > 90 hari dipilih | +| `Notification` | `system_notifications` | `created_at < 30 hari lalu` | Notifikasi lama dipilih | +| `AiHealingLog` | `ai_healing_logs` | `created_at < 90 hari lalu` | Log lama dipilih | +| `AiUsageLog` | `ai_usage_logs` | `created_at < 3 bulan lalu` | Log > 3 bulan dipilih | + +**Pola test per model:** +```php +// 1. Insert record yang HARUS di-prune +// 2. Insert record yang TIDAK BOLEH di-prune +// 3. Panggil prunable() dan periksa hasil +test('MobileErrorLog prunable selects records older than 90 days', function () { + \DB::table('mobile_error_logs')->insert([ + ['message' => 'old error', 'occurred_at' => now()->subDays(100)], // HARUS dipilih + ['message' => 'new error', 'occurred_at' => now()->subDays(5)], // TIDAK boleh + ]); + $ids = (new MobileErrorLog)->prunable()->pluck('id'); + expect($ids)->toHaveCount(1); +}); +``` + +**Edge cases yang diuji:** +- `OtpCode`: query mengembalikan empty collection saat semua kode masih valid +- `UserTrustedDevice`: query mengembalikan empty saat tidak ada device expired + +**Perbaikan kolom yang ditemukan selama whitebox testing:** + +| Model | Kolom Salah (awal) | Kolom Benar | +|-------|-------------------|-------------| +| `OtpCode` | `email` | `identifier` | +| `MobileSyncLog` | `device_id`, `created_at`, `updated_at` | Hanya `synced_at` | +| `MobileErrorLog` | `level`, `created_at`, `updated_at` | `message`, `occurred_at` | + +### 9.2 `CascadeIntegrityTest.php` — Integritas Cascade + +**File:** `tests/Feature/Database/CascadeIntegrityTest.php` + +Memverifikasi bahwa foreign key constraints dan cascade rules di database berfungsi dengan benar. + +| Test Case | Aksi | Expected Cascade | +|-----------|------|-----------------| +| Force delete user | `$user->forceDelete()` | `password_histories` dihapus | +| Force delete user | `$user->forceDelete()` | `user_consents` dihapus | +| Force delete user | `$user->forceDelete()` | `user_trusted_devices` dihapus | +| Force delete user | `$user->forceDelete()` | `system_settings.created_by/updated_by` → null | +| Force delete user | `$user->forceDelete()` | `system_setting_revisions.changed_by` → null | +| Delete role | `$role->forceDelete()` | `role_has_permissions` dihapus | +| Delete permission | `$perm->forceDelete()` | `role_has_permissions` dihapus | +| Force delete user (audit role) | `$actor->forceDelete()` | `roles.created_by/updated_by` → null | +| Soft delete user | `$user->delete()` | Related rows TETAP ada (tidak cascade) | + +**Mengapa penting:** Test ini membuktikan bahwa: +1. Hard delete tidak meninggalkan orphan records (data yatim) +2. Audit columns di-nullify ketika actor dihapus (tidak ada FK broken) +3. Soft delete **tidak** menghapus related records (intended behavior) + +--- + +## 10. Feature Tests — Fitur Sistem + +### 10.1 `SessionManagerTest.php` — Manajemen Session (BARU) + +**File:** `tests/Feature/System/SessionManagerTest.php` +**Target:** `app/Http/Controllers/SystemSettings/SessionManagerController.php` +**Endpoint:** `/session-manager` + +#### Kontrol Akses + +| Test Case | Expected | +|-----------|---------| +| Guest GET /session-manager | Redirect ke /login | +| User tanpa izin | 403 Forbidden | +| User dengan `view active sessions` | 200 OK | +| Guest GET /session-manager/stats (JSON) | **401 Unauthorized** (bukan redirect) | +| User viewer GET /session-manager/stats | 200 OK + `{total, active}` | + +> **Catatan:** JSON request dari guest mengembalikan 401, **bukan** 302. Ini adalah perilaku standar Laravel untuk request dengan header `Accept: application/json`. + +#### Statistik Struktur + +```php +test('stats endpoint returns valid json with total key', function () { + $this->actingAs(makeSessionViewer()) + ->getJson('/session-manager/stats') + ->assertOk() + ->assertJsonStructure(['total', 'active']); +}); +``` + +#### Terminasi Session + +| Test Case | Expected | +|-----------|---------| +| User tanpa `manage active sessions` | 403 Forbidden | +| Manager mencoba terminasi session sendiri | **403 Forbidden** (dikecualikan) | +| Manager terminasi session lain | 200 OK, `{success: true}` | +| Guest terminasi session | 401 Unauthorized | + +**Test "tidak bisa terminasi session sendiri"** menggunakan teknik khusus: +```php +test('manager cannot terminate their own current session', function () { + $manager = makeSessionManager(); + $this->actingAs($manager); + + // Panggil controller langsung — HTTP request baru akan punya session ID berbeda + $controller = app(\App\Http\Controllers\SystemSettings\SessionManagerController::class); + $currentId = session()->getId(); + + $response = $controller->destroy(request(), $currentId); + + expect($response->getStatusCode())->toBe(403); + expect(json_decode($response->getContent(), true)['success'])->toBeFalse(); +}); +``` + +> **Mengapa tidak via HTTP?** Karena driver session `array` membuat ID baru di setiap HTTP request test. Memanggil controller langsung via `app()` mempertahankan ID session yang sama sehingga kondisi "session sendiri" dapat diuji. + +### 10.2 `EditorUploadTest.php` — Upload Gambar CKEditor (BARU) + +**File:** `tests/Feature/System/EditorUploadTest.php` +**Target:** `app/Http/Controllers/SystemSettings/EditorUploadController.php` +**Endpoint:** `POST /editor/upload` + +Setup: `Storage::fake('public')` digunakan agar file tidak benar-benar disimpan ke disk. + +#### Kontrol Akses + +| Test Case | Expected | +|-----------|---------| +| Guest upload | **401 Unauthorized** | +| User tanpa `manage global settings` | 403 Forbidden | + +#### Validasi File + +| Test Case | Input | Expected | +|-----------|-------|---------| +| Tanpa file | Body kosong | 400, `{error.message: "No file uploaded."}` | +| File PHP | `shell.php` (mime: application/x-php) | 422 | +| File SVG | `vector.svg` (mime: image/svg+xml) | 422 (SVG bisa mengandung script) | +| File JPEG valid | `photo.jpg` | 200, `{uploaded: 1, url, fileName}` | +| File PNG valid | `icon.png` | 200, `{uploaded: 1}` | +| File WebP valid | `modern.webp` | 200, `{uploaded: 1}` | +| File > 5 MB | 6000 KB JPEG | 422 | + +> **Mengapa SVG ditolak?** SVG adalah format XML yang dapat mengandung script `', // judul berbahaya + 'message' => 'bold', + ]); + $response = $this->actingAs($user)->getJson('/notification-center/api/recent')->json(); + $titles = collect($response['notifications'])->pluck('title')->all(); + foreach ($titles as $t) { + expect($t)->not->toContain('` yang dieksekusi di browser semua penerima saat mereka mengklik kartu notifikasi. | +| **Risiko** | XSS stored — eksekusi script di browser user yang menerima notifikasi | +| **Perbaikan** | Menambahkan fungsi `escapeHtml()` di JS yang mengonversi `<`, `>`, `&`, `"`, `'` ke HTML entity **sebelum** regex markdown-lite dijalankan. Dengan ini ` + +
+ +
diff --git a/stubs/nwidart-stubs/inertia/component-vue.stub b/stubs/nwidart-stubs/inertia/component-vue.stub new file mode 100644 index 0000000..0496a8b --- /dev/null +++ b/stubs/nwidart-stubs/inertia/component-vue.stub @@ -0,0 +1,11 @@ + + + diff --git a/stubs/nwidart-stubs/inertia/page-react.stub b/stubs/nwidart-stubs/inertia/page-react.stub new file mode 100644 index 0000000..6fe4bc1 --- /dev/null +++ b/stubs/nwidart-stubs/inertia/page-react.stub @@ -0,0 +1,20 @@ +import { Head } from '@inertiajs/react' + +export default function $PAGE_NAME$({ }) { + return ( + <> + + +
+
+
+
+

$PAGE_NAME$

+

$STUDLY_NAME$ module - $PAGE_NAME$ page

+
+
+
+
+ + ) +} diff --git a/stubs/nwidart-stubs/inertia/page-svelte.stub b/stubs/nwidart-stubs/inertia/page-svelte.stub new file mode 100644 index 0000000..01b5daf --- /dev/null +++ b/stubs/nwidart-stubs/inertia/page-svelte.stub @@ -0,0 +1,18 @@ + + + + $STUDLY_NAME$ - $PAGE_NAME$ + + +
+
+
+
+

$PAGE_NAME$

+

$STUDLY_NAME$ module - $PAGE_NAME$ page

+
+
+
+
diff --git a/stubs/nwidart-stubs/inertia/page-vue.stub b/stubs/nwidart-stubs/inertia/page-vue.stub new file mode 100644 index 0000000..93d0e9c --- /dev/null +++ b/stubs/nwidart-stubs/inertia/page-vue.stub @@ -0,0 +1,24 @@ + + + diff --git a/stubs/nwidart-stubs/interface.stub b/stubs/nwidart-stubs/interface.stub new file mode 100644 index 0000000..e946b35 --- /dev/null +++ b/stubs/nwidart-stubs/interface.stub @@ -0,0 +1,5 @@ +view('view.name'); + } +} diff --git a/stubs/nwidart-stubs/middleware.stub b/stubs/nwidart-stubs/middleware.stub new file mode 100644 index 0000000..bdd192d --- /dev/null +++ b/stubs/nwidart-stubs/middleware.stub @@ -0,0 +1,17 @@ + + */ + public function share(Request $request): array + { + return array_merge(parent::share($request), [ + // + ]); + } +} diff --git a/stubs/nwidart-stubs/migration/add.stub b/stubs/nwidart-stubs/migration/add.stub new file mode 100644 index 0000000..788cdee --- /dev/null +++ b/stubs/nwidart-stubs/migration/add.stub @@ -0,0 +1,28 @@ +id(); + $FIELDS$ + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('$TABLE$'); + } +}; diff --git a/stubs/nwidart-stubs/migration/delete.stub b/stubs/nwidart-stubs/migration/delete.stub new file mode 100644 index 0000000..788cdee --- /dev/null +++ b/stubs/nwidart-stubs/migration/delete.stub @@ -0,0 +1,28 @@ +id(); + $FIELDS$ + $table->timestamps(); + }); + } +}; diff --git a/stubs/nwidart-stubs/migration/plain.stub b/stubs/nwidart-stubs/migration/plain.stub new file mode 100644 index 0000000..d94404c --- /dev/null +++ b/stubs/nwidart-stubs/migration/plain.stub @@ -0,0 +1,18 @@ +line('The introduction to the notification.') + ->action('Notification Action', 'https://laravel.com') + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification. + */ + public function toArray($notifiable): array + { + return []; + } +} diff --git a/stubs/nwidart-stubs/observer.stub b/stubs/nwidart-stubs/observer.stub new file mode 100644 index 0000000..e74fe90 --- /dev/null +++ b/stubs/nwidart-stubs/observer.stub @@ -0,0 +1,33 @@ +generator->getModule()->getLowerName(); + } +} diff --git a/stubs/nwidart-stubs/repository-invoke.stub b/stubs/nwidart-stubs/repository-invoke.stub new file mode 100644 index 0000000..ae29efc --- /dev/null +++ b/stubs/nwidart-stubs/repository-invoke.stub @@ -0,0 +1,8 @@ +mapApiRoutes(); + $this->mapWebRoutes(); + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + */ + protected function mapWebRoutes(): void + { + Route::middleware('web')->group(module_path($this->name, '$WEB_ROUTES_PATH$')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + */ + protected function mapApiRoutes(): void + { + Route::middleware('api')->prefix('api')->name('api.')->group(module_path($this->name, '$API_ROUTES_PATH$')); + } +} diff --git a/stubs/nwidart-stubs/routes/api.stub b/stubs/nwidart-stubs/routes/api.stub new file mode 100644 index 0000000..bd71ff0 --- /dev/null +++ b/stubs/nwidart-stubs/routes/api.stub @@ -0,0 +1,8 @@ +prefix('v1')->group(function () { + Route::apiResource('$PLURAL_LOWER_NAME$', $STUDLY_NAME$Controller::class)->names('$LOWER_NAME$'); +}); diff --git a/stubs/nwidart-stubs/routes/web.stub b/stubs/nwidart-stubs/routes/web.stub new file mode 100644 index 0000000..2da42a1 --- /dev/null +++ b/stubs/nwidart-stubs/routes/web.stub @@ -0,0 +1,8 @@ +group(function () { + Route::resource('$PLURAL_LOWER_NAME$', $STUDLY_NAME$Controller::class)->names('$LOWER_NAME$'); +}); diff --git a/stubs/nwidart-stubs/rule.implicit.stub b/stubs/nwidart-stubs/rule.implicit.stub new file mode 100644 index 0000000..635cbd2 --- /dev/null +++ b/stubs/nwidart-stubs/rule.implicit.stub @@ -0,0 +1,19 @@ + '$STUDLY_NAME$', +]; diff --git a/stubs/nwidart-stubs/scaffold/provider.stub b/stubs/nwidart-stubs/scaffold/provider.stub new file mode 100644 index 0000000..919773f --- /dev/null +++ b/stubs/nwidart-stubs/scaffold/provider.stub @@ -0,0 +1,154 @@ +registerCommands(); + $this->registerCommandSchedules(); + $this->registerTranslations(); + $this->registerConfig(); + $this->registerViews(); + $this->loadMigrationsFrom(module_path($this->name, '$MIGRATIONS_PATH$')); + } + + /** + * Register the service provider. + */ + public function register(): void + { + $this->app->register(EventServiceProvider::class); + $this->app->register(RouteServiceProvider::class); + } + + /** + * Register commands in the format of Command::class + */ + protected function registerCommands(): void + { + // $this->commands([]); + } + + /** + * Register command Schedules. + */ + protected function registerCommandSchedules(): void + { + // $this->app->booted(function () { + // $schedule = $this->app->make(Schedule::class); + // $schedule->command('inspire')->hourly(); + // }); + } + + /** + * Register translations. + */ + public function registerTranslations(): void + { + $langPath = resource_path('lang/modules/'.$this->nameLower); + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, $this->nameLower); + $this->loadJsonTranslationsFrom($langPath); + } else { + $this->loadTranslationsFrom(module_path($this->name, '$PATH_LANG$'), $this->nameLower); + $this->loadJsonTranslationsFrom(module_path($this->name, '$PATH_LANG$')); + } + } + + /** + * Register config. + */ + protected function registerConfig(): void + { + $configPath = module_path($this->name, config('modules.paths.generator.config.path')); + + if (is_dir($configPath)) { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($configPath)); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $config = str_replace($configPath.DIRECTORY_SEPARATOR, '', $file->getPathname()); + $config_key = str_replace([DIRECTORY_SEPARATOR, '.php'], ['.', ''], $config); + $segments = explode('.', $this->nameLower.'.'.$config_key); + + // Remove duplicated adjacent segments + $normalized = []; + foreach ($segments as $segment) { + if (end($normalized) !== $segment) { + $normalized[] = $segment; + } + } + + $key = ($config === 'config.php') ? $this->nameLower : implode('.', $normalized); + + $this->publishes([$file->getPathname() => config_path($config)], 'config'); + $this->merge_config_from($file->getPathname(), $key); + } + } + } + } + + /** + * Merge config from the given path recursively. + */ + protected function merge_config_from(string $path, string $key): void + { + $existing = config($key, []); + $module_config = require $path; + + config([$key => array_replace_recursive($existing, $module_config)]); + } + + /** + * Register views. + */ + public function registerViews(): void + { + $viewPath = resource_path('views/modules/'.$this->nameLower); + $sourcePath = module_path($this->name, '$PATH_VIEWS$'); + + $this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']); + + $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), $this->nameLower); + + Blade::componentNamespace(config('modules.namespace').'\\' . $this->name . '\\View\\Components', $this->nameLower); + } + + /** + * Get the services provided by the provider. + */ + public function provides(): array + { + return []; + } + + private function getPublishableViewPaths(): array + { + $paths = []; + foreach (config('view.paths') as $path) { + if (is_dir($path.'/modules/'.$this->nameLower)) { + $paths[] = $path.'/modules/'.$this->nameLower; + } + } + + return $paths; + } +} diff --git a/stubs/nwidart-stubs/scope.stub b/stubs/nwidart-stubs/scope.stub new file mode 100644 index 0000000..2bbe73f --- /dev/null +++ b/stubs/nwidart-stubs/scope.stub @@ -0,0 +1,15 @@ +call([]); + } +} diff --git a/stubs/nwidart-stubs/service-invoke.stub b/stubs/nwidart-stubs/service-invoke.stub new file mode 100644 index 0000000..ae29efc --- /dev/null +++ b/stubs/nwidart-stubs/service-invoke.stub @@ -0,0 +1,8 @@ +get('/'); + + $response->assertStatus(200); + } +} diff --git a/stubs/nwidart-stubs/tests/unit.stub b/stubs/nwidart-stubs/tests/unit.stub new file mode 100644 index 0000000..938e5da --- /dev/null +++ b/stubs/nwidart-stubs/tests/unit.stub @@ -0,0 +1,16 @@ +assertTrue(true); + } +} diff --git a/stubs/nwidart-stubs/trait.stub b/stubs/nwidart-stubs/trait.stub new file mode 100644 index 0000000..86f1151 --- /dev/null +++ b/stubs/nwidart-stubs/trait.stub @@ -0,0 +1,5 @@ + + + diff --git a/stubs/nwidart-stubs/views/index.stub b/stubs/nwidart-stubs/views/index.stub new file mode 100644 index 0000000..4cc0542 --- /dev/null +++ b/stubs/nwidart-stubs/views/index.stub @@ -0,0 +1,5 @@ + +

Hello World

+ +

Module: {!! config('$LOWER_NAME$.name') !!}

+
diff --git a/stubs/nwidart-stubs/views/master.stub b/stubs/nwidart-stubs/views/master.stub new file mode 100644 index 0000000..b97cddf --- /dev/null +++ b/stubs/nwidart-stubs/views/master.stub @@ -0,0 +1,30 @@ + + + + + + + + + + $STUDLY_NAME$ Module - {{ config('app.name', 'Laravel') }} + + + + + + + + + + {{-- Vite CSS --}} + {{-- {{ module_vite('build-$LOWER_NAME$', 'resources/assets/sass/app.scss') }} --}} + + + + {{ $slot }} + + {{-- Vite JS --}} + {{-- {{ module_vite('build-$LOWER_NAME$', 'resources/assets/js/app.js') }} --}} + + diff --git a/stubs/nwidart-stubs/vite.stub b/stubs/nwidart-stubs/vite.stub new file mode 100644 index 0000000..f1b0312 --- /dev/null +++ b/stubs/nwidart-stubs/vite.stub @@ -0,0 +1,57 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import { readdirSync, statSync } from 'fs'; +import { join,relative,dirname } from 'path'; +import { fileURLToPath } from 'url'; + +export default defineConfig({ + build: { + outDir: '../../public/build-$LOWER_NAME$', + emptyOutDir: true, + manifest: true, + }, + plugins: [ + laravel({ + publicDirectory: '../../public', + buildDirectory: 'build-$LOWER_NAME$', + input: [ + __dirname + '/resources/assets/sass/app.scss', + __dirname + '/resources/assets/js/app.js' + ], + refresh: true, + }), + ], +}); +// Scen all resources for assets file. Return array +//function getFilePaths(dir) { +// const filePaths = []; +// +// function walkDirectory(currentPath) { +// const files = readdirSync(currentPath); +// for (const file of files) { +// const filePath = join(currentPath, file); +// const stats = statSync(filePath); +// if (stats.isFile() && !file.startsWith('.')) { +// const relativePath = 'Modules/$STUDLY_NAME$/'+relative(__dirname, filePath); +// filePaths.push(relativePath); +// } else if (stats.isDirectory()) { +// walkDirectory(filePath); +// } +// } +// } +// +// walkDirectory(dir); +// return filePaths; +//} + +//const __filename = fileURLToPath(import.meta.url); +//const __dirname = dirname(__filename); + +//const assetsDir = join(__dirname, 'resources/assets'); +//export const paths = getFilePaths(assetsDir); + + +//export const paths = [ +// 'Modules/$STUDLY_NAME$/resources/assets/sass/app.scss', +// 'Modules/$STUDLY_NAME$/resources/assets/js/app.js', +//]; diff --git a/tests/Feature/AccessControl/PermissionManagementTest.php b/tests/Feature/AccessControl/PermissionManagementTest.php new file mode 100644 index 0000000..d75d2ee --- /dev/null +++ b/tests/Feature/AccessControl/PermissionManagementTest.php @@ -0,0 +1,116 @@ + 'manage access rights', 'guard_name' => 'web']); + Permission::firstOrCreate(['name' => 'view access rights', 'guard_name' => 'web']); + $user->givePermissionTo($perm); +} + +test('guest cannot access permissions index', function () { + $this->get('/permissions')->assertRedirect('/login'); +}); + +test('user without permission gets 403', function () { + $u = User::factory()->create(); + $this->actingAs($u)->get('/permissions')->assertForbidden(); +}); + +test('store creates a permission with web guard', function () { + $admin = User::factory()->create(); + grantManageAccessRightsForPerms($admin); + + $response = $this->actingAs($admin)->postJson('/permissions', [ + 'name' => 'view.reports', + 'guard_name' => 'web', + ]); + + $response->assertOk()->assertJson(['success' => true]); + $this->assertDatabaseHas('permissions', [ + 'name' => 'view.reports', + 'guard_name' => 'web', + ]); +}); + +test('same name allowed across different guards', function () { + $admin = User::factory()->create(); + grantManageAccessRightsForPerms($admin); + Permission::create(['name' => 'shared.perm', 'guard_name' => 'web']); + + $this->actingAs($admin)->postJson('/permissions', [ + 'name' => 'shared.perm', + 'guard_name' => 'api', + ])->assertOk(); +}); + +test('store rejects duplicate name within same guard', function () { + $admin = User::factory()->create(); + grantManageAccessRightsForPerms($admin); + Permission::create(['name' => 'duplicate.perm', 'guard_name' => 'web']); + + $this->actingAs($admin)->postJson('/permissions', [ + 'name' => 'duplicate.perm', + 'guard_name' => 'web', + ])->assertStatus(422); +}); + +test('store rejects invalid guard', function () { + $admin = User::factory()->create(); + grantManageAccessRightsForPerms($admin); + + $this->actingAs($admin)->postJson('/permissions', [ + 'name' => 'some.perm', + 'guard_name' => 'console', + ])->assertStatus(422); +}); + +test('store rejects illegal characters in name', function () { + $admin = User::factory()->create(); + grantManageAccessRightsForPerms($admin); + + $this->actingAs($admin)->postJson('/permissions', [ + 'name' => 'bad name with space!', + 'guard_name' => 'web', + ])->assertStatus(422); +}); + +test('update can rename a permission', function () { + $admin = User::factory()->create(); + grantManageAccessRightsForPerms($admin); + $p = Permission::create(['name' => 'old.name', 'guard_name' => 'web']); + + $this->actingAs($admin)->putJson("/permissions/{$p->id}", [ + 'name' => 'new.name', + 'guard_name' => 'web', + ])->assertOk(); + + expect($p->fresh()->name)->toBe('new.name'); +}); + +test('toggleStatus flips is_active', function () { + $admin = User::factory()->create(); + grantManageAccessRightsForPerms($admin); + $p = Permission::create(['name' => 'flip.able', 'guard_name' => 'web', 'is_active' => 1]); + + $this->actingAs($admin) + ->postJson('/permissions/toggle-status', ['id' => $p->id, 'status' => 'deactivate']) + ->assertOk(); + expect((bool) $p->fresh()->is_active)->toBeFalse(); + + $this->actingAs($admin) + ->postJson('/permissions/toggle-status', ['id' => $p->id, 'status' => 'activate']) + ->assertOk(); + expect((bool) $p->fresh()->is_active)->toBeTrue(); +}); + +test('destroy soft deletes permission', function () { + $admin = User::factory()->create(); + grantManageAccessRightsForPerms($admin); + $p = Permission::create(['name' => 'to.delete', 'guard_name' => 'web']); + + $this->actingAs($admin)->deleteJson("/permissions/{$p->id}")->assertOk(); + expect(Permission::withTrashed()->find($p->id)->trashed())->toBeTrue(); +}); diff --git a/tests/Feature/AccessControl/RoleManagementTest.php b/tests/Feature/AccessControl/RoleManagementTest.php new file mode 100644 index 0000000..a2f48ed --- /dev/null +++ b/tests/Feature/AccessControl/RoleManagementTest.php @@ -0,0 +1,193 @@ + 'manage access rights', 'guard_name' => 'web']); + Permission::firstOrCreate(['name' => 'view access rights', 'guard_name' => 'web']); + $user->givePermissionTo($perm); +} + +test('guest cannot access roles index', function () { + $this->get('/roles')->assertRedirect('/login'); +}); + +test('authenticated user without permission gets 403 on roles index', function () { + $user = User::factory()->create(); + $this->actingAs($user)->get('/roles')->assertForbidden(); +}); + +test('user with manage access rights can store a new role', function () { + $user = User::factory()->create(); + grantManageAccessRights($user); + $p = Permission::firstOrCreate(['name' => 'view dashboard', 'guard_name' => 'web']); + + $response = $this->actingAs($user) + ->postJson('/roles', [ + 'name' => 'editor-role', + 'guard_name' => 'web', + 'permissions' => [$p->id], + ]); + + $response->assertOk()->assertJson(['success' => true]); + $this->assertDatabaseHas('roles', ['name' => 'editor-role']); + + $role = Role::where('name', 'editor-role')->first(); + expect($role->hasPermissionTo('view dashboard'))->toBeTrue(); +}); + +test('store rejects duplicate role name', function () { + $user = User::factory()->create(); + grantManageAccessRights($user); + $p = Permission::firstOrCreate(['name' => 'view dashboard', 'guard_name' => 'web']); + Role::create(['name' => 'dup-role', 'guard_name' => 'web']); + + $response = $this->actingAs($user) + ->postJson('/roles', [ + 'name' => 'dup-role', + 'guard_name' => 'web', + 'permissions' => [$p->id], + ]); + + $response->assertStatus(422); +}); + +test('store rejects empty permissions array', function () { + $user = User::factory()->create(); + grantManageAccessRights($user); + + $response = $this->actingAs($user) + ->postJson('/roles', [ + 'name' => 'no-perm-role', + 'guard_name' => 'web', + 'permissions' => [], + ]); + + $response->assertStatus(422); +}); + +test('store rejects invalid characters in role name', function () { + $user = User::factory()->create(); + grantManageAccessRights($user); + $p = Permission::firstOrCreate(['name' => 'view dashboard', 'guard_name' => 'web']); + + $response = $this->actingAs($user) + ->postJson('/roles', [ + 'name' => 'role with space!', + 'guard_name' => 'web', + 'permissions' => [$p->id], + ]); + + $response->assertStatus(422); +}); + +test('update replaces role permissions', function () { + $user = User::factory()->create(); + grantManageAccessRights($user); + $p1 = Permission::firstOrCreate(['name' => 'view dashboard', 'guard_name' => 'web']); + $p2 = Permission::firstOrCreate(['name' => 'view user directory', 'guard_name' => 'web']); + $role = Role::create(['name' => 'original-role', 'guard_name' => 'web']); + $role->givePermissionTo('view dashboard'); + + $response = $this->actingAs($user) + ->putJson("/roles/{$role->id}", [ + 'name' => 'renamed-role', + 'guard_name' => 'web', + 'permissions' => [$p2->id], + ]); + + $response->assertOk(); + $role->refresh(); + expect($role->name)->toBe('renamed-role'); + expect($role->hasPermissionTo('view user directory'))->toBeTrue(); + expect($role->hasPermissionTo('view dashboard'))->toBeFalse(); +}); + +test('toggleStatus activates and deactivates role', function () { + $user = User::factory()->create(); + grantManageAccessRights($user); + $role = Role::create(['name' => 'toggleable', 'guard_name' => 'web', 'is_active' => true]); + + $this->actingAs($user) + ->postJson('/roles/toggle-status', ['id' => $role->id, 'status' => 'deactivate']) + ->assertOk(); + expect((bool) $role->fresh()->is_active)->toBeFalse(); + + $this->actingAs($user) + ->postJson('/roles/toggle-status', ['id' => $role->id, 'status' => 'activate']) + ->assertOk(); + expect((bool) $role->fresh()->is_active)->toBeTrue(); +}); + +test('destroy soft deletes role when no users assigned', function () { + $user = User::factory()->create(); + grantManageAccessRights($user); + $role = Role::create(['name' => 'doomed', 'guard_name' => 'web']); + + $this->actingAs($user) + ->deleteJson("/roles/{$role->id}") + ->assertOk(); + + expect(Role::withTrashed()->find($role->id)->trashed())->toBeTrue(); +}); + +test('destroy blocks deletion when role is still assigned to a user', function () { + $admin = User::factory()->create(); + grantManageAccessRights($admin); + $role = Role::create(['name' => 'in-use', 'guard_name' => 'web']); + $victim = User::factory()->create(); + $victim->assignRole($role); + + $this->actingAs($admin) + ->deleteJson("/roles/{$role->id}") + ->assertStatus(422) + ->assertJson(['success' => false]); + + expect(Role::find($role->id))->not->toBeNull(); +}); + +test('restore brings back a soft-deleted role', function () { + $user = User::factory()->create(); + grantManageAccessRights($user); + $role = Role::create(['name' => 'restorable', 'guard_name' => 'web']); + $role->delete(); + + $this->actingAs($user) + ->postJson("/roles/{$role->id}/restore") + ->assertOk(); + + expect(Role::find($role->id)->trashed())->toBeFalse(); +}); + +test('forceDelete permanently removes role', function () { + $user = User::factory()->create(); + grantManageAccessRights($user); + $role = Role::create(['name' => 'gone-forever', 'guard_name' => 'web']); + $role->delete(); + + $this->actingAs($user) + ->deleteJson("/roles/{$role->id}/force") + ->assertOk(); + + expect(Role::withTrashed()->find($role->id))->toBeNull(); +}); + +test('forceDelete blocks when role has users assigned', function () { + $admin = User::factory()->create(); + grantManageAccessRights($admin); + $role = Role::create(['name' => 'sticky', 'guard_name' => 'web']); + $victim = User::factory()->create(); + $victim->assignRole($role); + + $this->actingAs($admin) + ->deleteJson("/roles/{$role->id}/force") + ->assertStatus(422); + + expect(Role::find($role->id))->not->toBeNull(); +}); diff --git a/tests/Feature/AccessControl/UserManagementTest.php b/tests/Feature/AccessControl/UserManagementTest.php new file mode 100644 index 0000000..c85b8ad --- /dev/null +++ b/tests/Feature/AccessControl/UserManagementTest.php @@ -0,0 +1,198 @@ + 'manage user directory', 'guard_name' => 'web']); + Permission::firstOrCreate(['name' => 'view user directory', 'guard_name' => 'web']); + $user->givePermissionTo($perm); +} + +function defaultRole(): Role +{ + return Role::firstOrCreate(['name' => 'member', 'guard_name' => 'web']); +} + +const STRONG_PASSWORD = 'Str0ng!Passw0rd2026'; + +test('guest cannot access user index', function () { + $this->get('/users')->assertRedirect('/login'); +}); + +test('user without permission gets 403', function () { + $u = User::factory()->create(); + $this->actingAs($u)->get('/users')->assertForbidden(); +}); + +test('store creates user, hashes password, assigns roles', function () { + $admin = User::factory()->create(); + grantManageUserDirectory($admin); + $role = defaultRole(); + + $response = $this->actingAs($admin)->postJson('/users', [ + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + 'password' => STRONG_PASSWORD, + 'roles' => [$role->id], + ]); + + $response->assertOk()->assertJson(['success' => true]); + + $created = User::where('email', 'jane@example.com')->first(); + expect($created)->not->toBeNull(); + expect($created->password)->not->toBe(STRONG_PASSWORD); + expect($created->hasRole('member'))->toBeTrue(); +}); + +test('store rejects weak password', function () { + $admin = User::factory()->create(); + grantManageUserDirectory($admin); + $role = defaultRole(); + + $this->actingAs($admin)->postJson('/users', [ + 'name' => 'Weak Pass', + 'email' => 'weak@example.com', + 'password' => 'short', + 'roles' => [$role->id], + ])->assertStatus(422); +}); + +test('store rejects duplicate email', function () { + $admin = User::factory()->create(); + grantManageUserDirectory($admin); + $role = defaultRole(); + User::factory()->create(['email' => 'taken@example.com']); + + $this->actingAs($admin)->postJson('/users', [ + 'name' => 'Dup Email', + 'email' => 'taken@example.com', + 'password' => STRONG_PASSWORD, + 'roles' => [$role->id], + ])->assertStatus(422); +}); + +test('store rejects name with digits', function () { + $admin = User::factory()->create(); + grantManageUserDirectory($admin); + $role = defaultRole(); + + $this->actingAs($admin)->postJson('/users', [ + 'name' => 'John123', + 'email' => 'john@example.com', + 'password' => STRONG_PASSWORD, + 'roles' => [$role->id], + ])->assertStatus(422); +}); + +test('store requires at least one role', function () { + $admin = User::factory()->create(); + grantManageUserDirectory($admin); + + $this->actingAs($admin)->postJson('/users', [ + 'name' => 'No Role', + 'email' => 'norole@example.com', + 'password' => STRONG_PASSWORD, + 'roles' => [], + ])->assertStatus(422); +}); + +test('update can change name and reset roles without touching password', function () { + $admin = User::factory()->create(['name' => 'Admin User']); + grantManageUserDirectory($admin); + $r1 = defaultRole(); + $r2 = Role::firstOrCreate(['name' => 'editor', 'guard_name' => 'web']); + $target = User::factory()->create(['name' => 'Old Name']); + $target->assignRole($r1); + $originalHash = $target->password; + + $this->actingAs($admin)->putJson("/users/{$target->id}", [ + 'name' => 'Renamed User', + 'email' => $target->email, + 'roles' => [$r2->id], + ])->assertOk(); + + $target->refresh(); + expect($target->name)->toBe('Renamed User'); + expect($target->password)->toBe($originalHash); + expect($target->hasRole('editor'))->toBeTrue(); + expect($target->hasRole('member'))->toBeFalse(); +}); + +test('update changes password when provided', function () { + $admin = User::factory()->create(['name' => 'Admin User']); + grantManageUserDirectory($admin); + $role = defaultRole(); + $target = User::factory()->create(['name' => 'Target User']); + $target->assignRole($role); + $original = $target->password; + + $this->actingAs($admin)->putJson("/users/{$target->id}", [ + 'name' => 'Target User', + 'email' => $target->email, + 'password' => STRONG_PASSWORD, + 'roles' => [$role->id], + ])->assertOk(); + + expect($target->fresh()->password)->not->toBe($original); +}); + +test('toggleStatus deactivates and activates user', function () { + $admin = User::factory()->create(); + grantManageUserDirectory($admin); + $target = User::factory()->create(['is_active' => 1]); + + $this->actingAs($admin) + ->postJson('/users/toggle-status', ['id' => $target->id, 'status' => 'deactivate']) + ->assertOk(); + expect((bool) $target->fresh()->is_active)->toBeFalse(); + + $this->actingAs($admin) + ->postJson('/users/toggle-status', ['id' => $target->id, 'status' => 'activate']) + ->assertOk(); + expect((bool) $target->fresh()->is_active)->toBeTrue(); +}); + +test('destroy soft deletes user', function () { + $admin = User::factory()->create(); + grantManageUserDirectory($admin); + $target = User::factory()->create(); + + $this->actingAs($admin)->deleteJson("/users/{$target->id}")->assertOk(); + expect(User::withTrashed()->find($target->id)->trashed())->toBeTrue(); +}); + +test('restore brings soft-deleted user back', function () { + $admin = User::factory()->create(); + grantManageUserDirectory($admin); + $target = User::factory()->create(); + $target->delete(); + + $this->actingAs($admin)->postJson("/users/{$target->id}/restore")->assertOk(); + expect(User::find($target->id)->trashed())->toBeFalse(); +}); + +test('forceDelete blocks self-deletion', function () { + $admin = User::factory()->create(); + grantManageUserDirectory($admin); + $admin->delete(); + + $this->actingAs($admin) + ->deleteJson("/users/{$admin->id}/force") + ->assertStatus(403) + ->assertJson(['success' => false]); + + expect(User::withTrashed()->find($admin->id))->not->toBeNull(); +}); + +test('forceDelete permanently removes another user', function () { + $admin = User::factory()->create(); + grantManageUserDirectory($admin); + $target = User::factory()->create(); + $target->delete(); + + $this->actingAs($admin)->deleteJson("/users/{$target->id}/force")->assertOk(); + expect(User::withTrashed()->find($target->id))->toBeNull(); +}); diff --git a/tests/Feature/Api/ApiAuthExtendedTest.php b/tests/Feature/Api/ApiAuthExtendedTest.php new file mode 100644 index 0000000..d210063 --- /dev/null +++ b/tests/Feature/Api/ApiAuthExtendedTest.php @@ -0,0 +1,199 @@ +getProperty('resolvedSettings'); + $prop->setAccessible(true); + $prop->setValue(null, null); + Cache::flush(); + + Role::findOrCreate('User', 'web'); +}); + +// ── deleteAccount ───────────────────────────────────────────────────────────── + +test('delete account succeeds when correct password is supplied', function () { + $user = User::factory()->create(['password' => Hash::make('secret')]); + $token = $user->createToken('app')->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->deleteJson('/api/v1/profile/delete', ['password' => 'secret']) + ->assertOk() + ->assertJsonPath('status', 'success'); + + expect(User::find($user->id))->toBeNull(); +}); + +test('delete account is rejected with wrong password', function () { + $user = User::factory()->create(['password' => Hash::make('secret')]); + $token = $user->createToken('app')->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->deleteJson('/api/v1/profile/delete', ['password' => 'wrong']) + ->assertStatus(422); + + expect(User::find($user->id))->not->toBeNull(); +}); + +test('delete account requires password field', function () { + $user = User::factory()->create(); + $token = $user->createToken('app')->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->deleteJson('/api/v1/profile/delete', []) + ->assertStatus(422); +}); + +test('delete account is not accessible to guests', function () { + $this->deleteJson('/api/v1/profile/delete', ['password' => 'x']) + ->assertUnauthorized(); +}); + +test('delete account also revokes all user tokens', function () { + $user = User::factory()->create(['password' => Hash::make('secret')]); + $user->createToken('device-a'); + $user->createToken('device-b'); + $token = $user->createToken('device-c')->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->deleteJson('/api/v1/profile/delete', ['password' => 'secret']) + ->assertOk(); + + expect($user->tokens()->count())->toBe(0); +}); + +// ── updatePassword (API) ────────────────────────────────────────────────────── + +test('api password update succeeds with valid credentials', function () { + $user = User::factory()->create(['password' => Hash::make('old-pass')]); + $token = $user->createToken('app')->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/v1/profile/password', [ + 'current_password' => 'old-pass', + 'password' => 'New-Api-Pass1', + 'password_confirmation' => 'New-Api-Pass1', + ]) + ->assertOk() + ->assertJsonPath('status', 'success'); + + expect(Hash::check('New-Api-Pass1', $user->fresh()->password))->toBeTrue(); +}); + +test('api password update is rejected when current password is wrong', function () { + $user = User::factory()->create(['password' => Hash::make('real-pass')]); + $token = $user->createToken('app')->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/v1/profile/password', [ + 'current_password' => 'not-real', + 'password' => 'New-Api-Pass2', + 'password_confirmation' => 'New-Api-Pass2', + ]) + ->assertStatus(422); +}); + +test('api password update is rejected when reusing a history password', function () { + app(SystemConfigService::class)->update(['password_history_count' => 3]); + + $user = User::factory()->create(['password' => Hash::make('current')]); + PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('Recycled-Api!1')]); + $token = $user->createToken('app')->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/v1/profile/password', [ + 'current_password' => 'current', + 'password' => 'Recycled-Api!1', + 'password_confirmation' => 'Recycled-Api!1', + ]) + ->assertStatus(422) + ->assertJsonPath('status', 'error'); +}); + +test('api password update records change in password history', function () { + app(SystemConfigService::class)->update(['password_history_count' => 5]); + + $user = User::factory()->create(['password' => Hash::make('old')]); + $token = $user->createToken('app')->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/v1/profile/password', [ + 'current_password' => 'old', + 'password' => 'Api-NewPass-99', + 'password_confirmation' => 'Api-NewPass-99', + ]); + + expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(1); +}); + +// ── register with PasswordPolicyService ────────────────────────────────────── + +test('register enforces min length from password policy setting', function () { + app(SystemConfigService::class)->update(['password_min_length' => 12]); + + $this->postJson('/api/v1/register', [ + 'name' => 'Test', + 'email' => 'policy@example.com', + 'password' => 'short', + ])->assertStatus(422); +}); + +test('register succeeds when password meets policy min length', function () { + app(SystemConfigService::class)->update(['password_min_length' => 6]); + + $this->postJson('/api/v1/register', [ + 'name' => 'Policy User', + 'email' => 'policy2@example.com', + 'password' => 'longenough', + ])->assertStatus(201); +}); + +test('register enforces numeric requirement when setting is enabled', function () { + app(SystemConfigService::class)->update([ + 'password_require_numeric' => true, + 'password_min_length' => 6, + ]); + + $this->postJson('/api/v1/register', [ + 'name' => 'No Digits', + 'email' => 'nodigits@example.com', + 'password' => 'NoDigitsHere', + ])->assertStatus(422); +}); + +// ── updateProfile ───────────────────────────────────────────────────────────── + +test('authenticated user can update their name via api', function () { + $user = User::factory()->create(); + $token = $user->createToken('app')->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/v1/profile/update', [ + 'name' => 'Updated Name', + 'email' => $user->email, + ]) + ->assertOk() + ->assertJsonPath('status', 'success'); + + expect($user->fresh()->name)->toBe('Updated Name'); +}); + +test('profile update rejects name longer than 255 chars', function () { + $user = User::factory()->create(); + $token = $user->createToken('app')->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/v1/profile/update', [ + 'name' => str_repeat('a', 256), + 'email' => $user->email, + ]) + ->assertStatus(422); +}); diff --git a/tests/Feature/Api/AuthTest.php b/tests/Feature/Api/AuthTest.php new file mode 100644 index 0000000..1bc9576 --- /dev/null +++ b/tests/Feature/Api/AuthTest.php @@ -0,0 +1,101 @@ +postJson('/api/v1/register', [ + 'name' => 'Test User', + 'email' => 'newuser@example.com', + 'password' => 'password123', + ]); + + $response->assertStatus(201) + ->assertJsonPath('status', 'success') + ->assertJsonStructure(['data' => ['user', 'token']]); +}); + +test('register rejects duplicate email', function () { + User::factory()->create(['email' => 'existing@example.com']); + + $this->postJson('/api/v1/register', [ + 'name' => 'Another', + 'email' => 'existing@example.com', + 'password' => 'password123', + ])->assertStatus(422); +}); + +// ── Login ──────────────────────────────────────────────────────────────────── + +test('user can login with valid credentials', function () { + $user = User::factory()->create([ + 'password' => Hash::make('secret123'), + 'is_active' => true, + ]); + $user->assignRole('User'); + + $this->postJson('/api/v1/login', [ + 'email' => $user->email, + 'password' => 'secret123', + ])->assertOk() + ->assertJsonPath('status', 'success') + ->assertJsonStructure(['data' => ['token']]); +}); + +test('login rejects wrong password', function () { + $user = User::factory()->create(['password' => Hash::make('correct')]); + + $this->postJson('/api/v1/login', [ + 'email' => $user->email, + 'password' => 'wrong', + ])->assertUnauthorized() + ->assertJsonPath('status', 'error'); +}); + +test('login rejects inactive user', function () { + $user = User::factory()->create([ + 'password' => Hash::make('password'), + 'is_active' => false, + ]); + + $this->postJson('/api/v1/login', [ + 'email' => $user->email, + 'password' => 'password', + ])->assertForbidden(); +}); + +// ── Logout ─────────────────────────────────────────────────────────────────── + +test('authenticated user can logout', function () { + $user = User::factory()->create(); + $token = $user->createToken('test')->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/v1/logout') + ->assertOk() + ->assertJsonPath('status', 'success'); +}); + +test('unauthenticated request to logout returns 401', function () { + $this->postJson('/api/v1/logout')->assertUnauthorized(); +}); + +// ── Get User ───────────────────────────────────────────────────────────────── + +test('authenticated user can fetch own profile', function () { + $user = User::factory()->create(); + + $this->actingAs($user, 'sanctum') + ->getJson('/api/v1/user') + ->assertOk() + ->assertJsonPath('status', 'success') + ->assertJsonPath('data.user.email', $user->email); +}); diff --git a/tests/Feature/Api/DeviceTokenTest.php b/tests/Feature/Api/DeviceTokenTest.php new file mode 100644 index 0000000..b15c2e9 --- /dev/null +++ b/tests/Feature/Api/DeviceTokenTest.php @@ -0,0 +1,58 @@ +postJson('/api/v1/devices/register', [ + 'token' => 'some-fcm-token', + 'platform' => 'android', + ])->assertUnauthorized(); +}); + +test('authenticated user can register device token', function () { + $user = User::factory()->create(); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/v1/devices/register', [ + 'token' => 'fcm-token-'.uniqid(), + 'platform' => 'android', + 'device_name' => 'Samsung Galaxy', + 'app_version' => '1.2.3', + ])->assertOk() + ->assertJsonPath('status', 'success') + ->assertJsonStructure(['data' => ['device_id']]); +}); + +test('duplicate token upserts without error', function () { + $user = User::factory()->create(); + $token = 'fcm-stable-token'; + + $this->actingAs($user, 'sanctum') + ->postJson('/api/v1/devices/register', ['token' => $token, 'platform' => 'ios']) + ->assertOk(); + + // Second call with same token — should update, not duplicate + $this->actingAs($user, 'sanctum') + ->postJson('/api/v1/devices/register', ['token' => $token, 'platform' => 'ios']) + ->assertOk(); + + expect(DeviceToken::where('token', $token)->count())->toBe(1); +}); + +test('authenticated user can unregister device token', function () { + $user = User::factory()->create(); + $token = 'fcm-token-to-remove'; + + DeviceToken::create([ + 'user_id' => $user->id, + 'token' => $token, + 'platform' => 'android', + ]); + + $this->actingAs($user, 'sanctum') + ->deleteJson('/api/v1/devices/unregister', ['token' => $token]) + ->assertOk(); + + expect(DeviceToken::where('token', $token)->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Api/HealthTest.php b/tests/Feature/Api/HealthTest.php new file mode 100644 index 0000000..1cca542 --- /dev/null +++ b/tests/Feature/Api/HealthTest.php @@ -0,0 +1,27 @@ +getJson('/api/health'); + + $response->assertOk() + ->assertJsonStructure(['status', 'timestamp', 'checks' => ['database', 'storage', 'queue']]); + + expect($response->json('status'))->toBeIn(['healthy', 'warn']); +}); + +test('health endpoint returns JSON with timestamp', function () { + $response = $this->getJson('/api/health'); + + $response->assertOk()->assertJsonStructure(['timestamp']); + + expect($response->json('timestamp'))->toBeString(); +}); + +test('health endpoint reports per-check status keys', function () { + $checks = $this->getJson('/api/health')->json('checks'); + + foreach (['database', 'redis', 'storage', 'queue'] as $key) { + expect($checks)->toHaveKey($key); + expect($checks[$key]['status'])->toBeIn(['ok', 'warn', 'fail', 'unknown']); + } +}); diff --git a/tests/Feature/Api/OtpTest.php b/tests/Feature/Api/OtpTest.php new file mode 100644 index 0000000..84df648 --- /dev/null +++ b/tests/Feature/Api/OtpTest.php @@ -0,0 +1,31 @@ +postJson('/api/v1/otp/send', []) + ->assertStatus(422); +}); + +test('otp send accepts valid email and queues mail', function () { + Mail::fake(); + + $this->postJson('/api/v1/otp/send', ['email' => 'test@example.com']) + ->assertOk() + ->assertJsonPath('status', 'success'); +}); + +test('otp verify rejects invalid code', function () { + $this->postJson('/api/v1/otp/verify', [ + 'email' => 'test@example.com', + 'code' => '000000', + ])->assertStatus(422) + ->assertJsonPath('status', 'error'); +}); + +test('otp verify requires 6-digit code', function () { + $this->postJson('/api/v1/otp/verify', [ + 'email' => 'test@example.com', + 'code' => '123', + ])->assertStatus(422); +}); diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php new file mode 100644 index 0000000..a272b9d --- /dev/null +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -0,0 +1,41 @@ +get('/login'); + + $response->assertStatus(200); +}); + +test('users can authenticate using the login screen', function () { + $user = User::factory()->create(); + + $response = $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(route('dashboard', absolute: false)); +}); + +test('users can not authenticate with invalid password', function () { + $user = User::factory()->create(); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]); + + $this->assertGuest(); +}); + +test('users can logout', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/logout'); + + $this->assertGuest(); + $response->assertRedirect('/'); +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 0000000..f282dff --- /dev/null +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,46 @@ +unverified()->create(); + + $response = $this->actingAs($user)->get('/verify-email'); + + $response->assertStatus(200); +}); + +test('email can be verified', function () { + $user = User::factory()->unverified()->create(); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + Event::assertDispatched(Verified::class); + expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); + $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); +}); + +test('email is not verified with invalid hash', function () { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong-email')] + ); + + $this->actingAs($user)->get($verificationUrl); + + expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); +}); diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php new file mode 100644 index 0000000..8a42902 --- /dev/null +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -0,0 +1,32 @@ +create(); + + $response = $this->actingAs($user)->get('/confirm-password'); + + $response->assertStatus(200); +}); + +test('password can be confirmed', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/confirm-password', [ + 'password' => 'password', + ]); + + $response->assertRedirect(); + $response->assertSessionHasNoErrors(); +}); + +test('password is not confirmed with invalid password', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/confirm-password', [ + 'password' => 'wrong-password', + ]); + + $response->assertSessionHasErrors(); +}); diff --git a/tests/Feature/Auth/PasswordControllerTest.php b/tests/Feature/Auth/PasswordControllerTest.php new file mode 100644 index 0000000..b93cb03 --- /dev/null +++ b/tests/Feature/Auth/PasswordControllerTest.php @@ -0,0 +1,176 @@ +getProperty('resolvedSettings'); + $prop->setAccessible(true); + $prop->setValue(null, null); + Cache::flush(); +}); + +// ── Happy path ──────────────────────────────────────────────────────────────── + +test('password update succeeds with valid current password', function () { + $user = User::factory()->create(['password' => Hash::make('current-pass')]); + + $this->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'current-pass', + 'password' => 'New-Pass-123', + 'password_confirmation' => 'New-Pass-123', + ]) + ->assertRedirect('/profile') + ->assertSessionHasNoErrors(); + + expect(Hash::check('New-Pass-123', $user->fresh()->password))->toBeTrue(); +}); + +test('password update stamps password_changed_at', function () { + app(SystemConfigService::class)->update(['password_history_count' => 0]); + + $user = User::factory()->create([ + 'password' => Hash::make('current-pass'), + 'password_changed_at' => null, + ]); + + $this->actingAs($user)->put('/password', [ + 'current_password' => 'current-pass', + 'password' => 'New-Pass-456', + 'password_confirmation' => 'New-Pass-456', + ]); + + expect($user->fresh()->password_changed_at)->not->toBeNull(); +}); + +// ── Validation ──────────────────────────────────────────────────────────────── + +test('wrong current password is rejected', function () { + $user = User::factory()->create(['password' => Hash::make('real-pass')]); + + $this->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'wrong-pass', + 'password' => 'New-Pass-789', + 'password_confirmation' => 'New-Pass-789', + ]) + ->assertSessionHasErrorsIn('updatePassword', 'current_password'); + + expect(Hash::check('real-pass', $user->fresh()->password))->toBeTrue(); +}); + +test('mismatched confirmation is rejected', function () { + $user = User::factory()->create(['password' => Hash::make('correct')]); + + $this->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'correct', + 'password' => 'New-Pass-Abc', + 'password_confirmation' => 'Different-Xyz', + ]) + ->assertSessionHasErrors(); +}); + +// ── History enforcement ─────────────────────────────────────────────────────── + +test('reusing a recent password is rejected with history enabled', function () { + app(SystemConfigService::class)->update(['password_history_count' => 3]); + + $oldHash = Hash::make('OldPassword1!'); + $user = User::factory()->create(['password' => Hash::make('current-pass')]); + PasswordHistory::create(['user_id' => $user->id, 'password' => $oldHash]); + + $this->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'current-pass', + 'password' => 'OldPassword1!', + 'password_confirmation' => 'OldPassword1!', + ]) + ->assertSessionHasErrors(); +}); + +test('history check is skipped when history_count is zero', function () { + app(SystemConfigService::class)->update(['password_history_count' => 0]); + + $oldHash = Hash::make('Recycled-Pass!1'); + $user = User::factory()->create(['password' => Hash::make('current-pass')]); + PasswordHistory::create(['user_id' => $user->id, 'password' => $oldHash]); + + $this->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'current-pass', + 'password' => 'Recycled-Pass!1', + 'password_confirmation' => 'Recycled-Pass!1', + ]) + ->assertSessionHasNoErrors(); +}); + +// ── History recording ───────────────────────────────────────────────────────── + +test('successful password change records new entry in password history', function () { + app(SystemConfigService::class)->update(['password_history_count' => 5]); + + $user = User::factory()->create(['password' => Hash::make('current-pass')]); + + $this->actingAs($user)->put('/password', [ + 'current_password' => 'current-pass', + 'password' => 'Brand-New-777!', + 'password_confirmation' => 'Brand-New-777!', + ]); + + expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(1); +}); + +// ── old password no longer works after update ───────────────────────────────── + +test('old password cannot be used to log in after successful update', function () { + $user = User::factory()->create(['password' => Hash::make('old-pass-456')]); + + $this->actingAs($user)->put('/password', [ + 'current_password' => 'old-pass-456', + 'password' => 'Brand-New-888!', + 'password_confirmation' => 'Brand-New-888!', + ]); + + // Old password must not match the new stored hash + expect(Hash::check('old-pass-456', $user->fresh()->password))->toBeFalse(); + // New password must match + expect(Hash::check('Brand-New-888!', $user->fresh()->password))->toBeTrue(); +}); + +// ── Guest ───────────────────────────────────────────────────────────────────── + +test('guest cannot update password', function () { + $this->put('/password', [ + 'current_password' => 'x', + 'password' => 'y', + 'password_confirmation' => 'y', + ])->assertRedirect('/login'); +}); + +// ── JSON response ───────────────────────────────────────────────────────────── + +test('json request receives json success response', function () { + $user = User::factory()->create(['password' => Hash::make('current-pass')]); + + $this->actingAs($user) + ->putJson('/password', [ + 'current_password' => 'current-pass', + 'password' => 'Json-Pass-1!', + 'password_confirmation' => 'Json-Pass-1!', + ]) + ->assertOk() + ->assertJsonPath('success', true); +}); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php new file mode 100644 index 0000000..b5ee502 --- /dev/null +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -0,0 +1,60 @@ +get('/forgot-password'); + + $response->assertStatus(200); +}); + +test('reset password link can be requested', function () { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class); +}); + +test('reset password screen can be rendered', function () { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) { + $response = $this->get('/reset-password/'.$notification->token); + + $response->assertStatus(200); + + return true; + }); +}); + +test('password can be reset with valid token', function () { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + $response = $this->post('/reset-password', [ + 'token' => $notification->token, + 'email' => $user->email, + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('login')); + + return true; + }); +}); diff --git a/tests/Feature/Auth/PasswordUpdateTest.php b/tests/Feature/Auth/PasswordUpdateTest.php new file mode 100644 index 0000000..e3d1278 --- /dev/null +++ b/tests/Feature/Auth/PasswordUpdateTest.php @@ -0,0 +1,40 @@ +create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $this->assertTrue(Hash::check('new-password', $user->refresh()->password)); +}); + +test('correct password must be provided to update password', function () { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'wrong-password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasErrorsIn('updatePassword', 'current_password') + ->assertRedirect('/profile'); +}); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php new file mode 100644 index 0000000..127a94b --- /dev/null +++ b/tests/Feature/Auth/RegistrationTest.php @@ -0,0 +1,25 @@ +get('/register'); + + $response->assertStatus(200); +}); + +test('new users can register', function () { + // Ensure the default role exists (not seeded in test DB) + Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']); + + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'Password1!', + 'password_confirmation' => 'Password1!', + 'agree_tos_pdp' => '1', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(route('dashboard', absolute: false)); +}); diff --git a/tests/Feature/Auth/SessionFixationTest.php b/tests/Feature/Auth/SessionFixationTest.php new file mode 100644 index 0000000..6563953 --- /dev/null +++ b/tests/Feature/Auth/SessionFixationTest.php @@ -0,0 +1,131 @@ +getProperty('resolvedSettings'); + $prop->setAccessible(true); + $prop->setValue(null, null); + Cache::flush(); +}); + +// ── Web login ───────────────────────────────────────────────────────────────── + +test('web login regenerates the session id', function () { + $user = User::factory()->create(); + $before = session()->getId(); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + // After login the session must have a different ID + expect(session()->getId())->not->toBe($before); +}); + +// ── 2FA verify ──────────────────────────────────────────────────────────────── + +test('2fa verify regenerates the session id', function () { + $user = User::factory()->create(); + Session::put('auth.2fa_user_id', $user->id); + Session::put('auth.2fa_code', '123456'); + Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp); + + $before = session()->getId(); + + $this->post('/2fa', ['code' => '123456']); + + expect(session()->getId())->not->toBe($before); +}); + +// ── OAuth / Socialite callback ──────────────────────────────────────────────── + +test('oauth callback regenerates the session id after successful login', function () { + Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']); + app(SystemConfigService::class)->update(['feature_google_oauth' => true]); + session(['social_auth_provider' => 'google']); + + $socialUser = new Laravel\Socialite\Two\User; + $socialUser->id = 'google-fixation-id'; + $socialUser->name = 'Fixation User'; + $socialUser->email = 'fixation@example.com'; + $socialUser->avatar = ''; + $socialUser->user = []; + + Socialite::shouldReceive('driver->user')->andReturn($socialUser); + + $before = session()->getId(); + + $this->get('/auth/callback')->assertRedirect('/dashboard'); + + expect(session()->getId())->not->toBe($before); +}); + +// ── Password reset ──────────────────────────────────────────────────────────── + +test('password reset regenerates the session id', function () { + Notification::fake(); + $user = User::factory()->create(); + + // Request a reset token + $this->post('/forgot-password', ['email' => $user->email]); + $token = Password::broker()->createToken($user); + + $before = session()->getId(); + + $this->post('/reset-password', [ + 'token' => $token, + 'email' => $user->email, + 'password' => 'NewSecurePass1!', + 'password_confirmation' => 'NewSecurePass1!', + ])->assertRedirect(route('login')); + + expect(session()->getId())->not->toBe($before); +}); + +// ── Impersonation ───────────────────────────────────────────────────────────── + +test('starting impersonation regenerates the session id', function () { + $perm = \App\Models\Permission::firstOrCreate(['name' => 'impersonate users', 'guard_name' => 'web']); + $admin = User::factory()->create(['is_active' => true]); + $admin->givePermissionTo($perm); + $target = User::factory()->create(['is_active' => true]); + + $before = session()->getId(); + + $this->actingAs($admin)->post("/impersonate/{$target->id}"); + + expect(session()->getId())->not->toBe($before); +}); + +test('stopping impersonation regenerates the session id', function () { + $perm = \App\Models\Permission::firstOrCreate(['name' => 'impersonate users', 'guard_name' => 'web']); + $admin = User::factory()->create(['is_active' => true]); + $admin->givePermissionTo($perm); + $target = User::factory()->create(['is_active' => true]); + + // Start impersonation first + $this->actingAs($admin)->post("/impersonate/{$target->id}"); + + $before = session()->getId(); + + $this->post('/impersonate/stop'); + + expect(session()->getId())->not->toBe($before); +}); diff --git a/tests/Feature/Auth/SocialAuthTest.php b/tests/Feature/Auth/SocialAuthTest.php new file mode 100644 index 0000000..4121aa7 --- /dev/null +++ b/tests/Feature/Auth/SocialAuthTest.php @@ -0,0 +1,156 @@ +getProperty('resolvedSettings'); + $prop->setAccessible(true); + $prop->setValue(null, null); + Cache::flush(); + Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']); +}); + +function enableOauth(string $provider): void +{ + app(SystemConfigService::class)->update(['feature_'.$provider.'_oauth' => true]); +} + +function fakeSocialiteUser(array $overrides = []): SocialiteUserContract +{ + $u = new Laravel\Socialite\Two\User; + $u->id = $overrides['id'] ?? 'oauth-id-123'; + $u->name = $overrides['name'] ?? 'OAuth User'; + $u->email = $overrides['email'] ?? 'oauth@example.com'; + $u->avatar = $overrides['avatar'] ?? 'https://example.com/avatar.png'; + $u->user = $overrides['user'] ?? []; + + return $u; +} + +test('redirect returns 404 when provider feature is disabled', function () { + $this->get('/auth/google')->assertNotFound(); +}); + +test('redirect issues a redirect when provider feature is enabled', function () { + enableOauth('google'); + + Socialite::shouldReceive('driver->redirect') + ->andReturn(redirect('https://accounts.google.com/o/oauth2/auth?fake=1')); + + $this->get('/auth/google')->assertRedirect(); + expect(session('social_auth_provider'))->toBe('google'); +}); + +test('callback without provider session redirects to login with error', function () { + $this->get('/auth/callback') + ->assertRedirect('/login') + ->assertSessionHas('error'); +}); + +test('callback rejects unverified email from provider', function () { + enableOauth('google'); + session(['social_auth_provider' => 'google']); + + Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([ + 'user' => ['email_verified' => false], + ])); + + $this->get('/auth/callback') + ->assertRedirect('/login') + ->assertSessionHas('error'); + + $this->assertGuest(); +}); + +test('callback creates a new user via provider id and assigns user role', function () { + enableOauth('google'); + session(['social_auth_provider' => 'google']); + + Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([ + 'id' => 'google-uid-9', + 'email' => 'fresh@example.com', + 'name' => 'Fresh User', + ])); + + $this->get('/auth/callback')->assertRedirect('/dashboard'); + + $user = User::where('email', 'fresh@example.com')->first(); + expect($user)->not->toBeNull(); + expect($user->google_id)->toBe('google-uid-9'); + expect($user->hasRole('User'))->toBeTrue(); +}); + +test('callback links to existing user with matching email when no provider id yet', function () { + enableOauth('google'); + session(['social_auth_provider' => 'google']); + $existing = User::factory()->create([ + 'email' => 'link@example.com', + 'google_id' => null, + ]); + + Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([ + 'id' => 'google-uid-link', + 'email' => 'link@example.com', + ])); + + $this->get('/auth/callback')->assertRedirect('/dashboard'); + + expect($existing->fresh()->google_id)->toBe('google-uid-link'); + $this->assertAuthenticatedAs($existing->fresh()); +}); + +test('callback refuses to overwrite a different existing oauth identity', function () { + enableOauth('google'); + session(['social_auth_provider' => 'google']); + + $existing = User::factory()->create([ + 'email' => 'taken@example.com', + 'google_id' => 'different-google-id', + ]); + + Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([ + 'id' => 'attacker-id', + 'email' => 'taken@example.com', + ])); + + $this->get('/auth/callback') + ->assertRedirect('/login') + ->assertSessionHas('error'); + + expect($existing->fresh()->google_id)->toBe('different-google-id'); + $this->assertGuest(); +}); + +test('callback re-uses user matched by provider id', function () { + enableOauth('google'); + session(['social_auth_provider' => 'google']); + + $existing = User::factory()->create(['google_id' => 'stable-id']); + + Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([ + 'id' => 'stable-id', + 'email' => $existing->email, + ])); + + $this->get('/auth/callback')->assertRedirect('/dashboard'); + $this->assertAuthenticatedAs($existing->fresh()); +}); + +test('callback on socialite exception redirects to login with error', function () { + enableOauth('google'); + session(['social_auth_provider' => 'google']); + + Socialite::shouldReceive('driver->user')->andThrow(new Exception('OAuth boom')); + + $this->get('/auth/callback') + ->assertRedirect('/login') + ->assertSessionHas('error'); + + $this->assertGuest(); +}); diff --git a/tests/Feature/Auth/TwoFactorTest.php b/tests/Feature/Auth/TwoFactorTest.php new file mode 100644 index 0000000..b18cb97 --- /dev/null +++ b/tests/Feature/Auth/TwoFactorTest.php @@ -0,0 +1,136 @@ +get('/2fa')->assertRedirect(route('login', absolute: false)); +}); + +test('2fa view renders when 2fa session is set', function () { + $user = User::factory()->create(); + + Session::put('auth.2fa_user_id', $user->id); + Session::put('auth.2fa_code', '654321'); + Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp); + + $this->get('/2fa')->assertOk()->assertViewIs('auth.two-factor'); +}); + +test('verify with correct code logs the user in', function () { + $user = User::factory()->create(); + + Session::put('auth.2fa_user_id', $user->id); + Session::put('auth.2fa_code', '111222'); + Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp); + + $this->post('/2fa', ['code' => '111222']) + ->assertRedirect(route('dashboard', absolute: false)); + + $this->assertAuthenticatedAs($user); +}); + +test('verify with wrong code keeps user logged out', function () { + $user = User::factory()->create(); + Session::put('auth.2fa_user_id', $user->id); + Session::put('auth.2fa_code', '111222'); + Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp); + + $this->post('/2fa', ['code' => '999999']) + ->assertSessionHas('error'); + + $this->assertGuest(); +}); + +test('verify with expired code redirects to login', function () { + $user = User::factory()->create(); + Session::put('auth.2fa_user_id', $user->id); + Session::put('auth.2fa_code', '111222'); + Session::put('auth.2fa_expires_at', now()->subSecond()->timestamp); + + $this->post('/2fa', ['code' => '111222']) + ->assertRedirect(route('login', absolute: false)) + ->assertSessionHas('error'); + + $this->assertGuest(); +}); + +test('verify rejects code with wrong length', function () { + $user = User::factory()->create(); + Session::put('auth.2fa_user_id', $user->id); + Session::put('auth.2fa_code', '111222'); + Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp); + + $this->post('/2fa', ['code' => '12345'])->assertSessionHasErrors('code'); + $this->assertGuest(); +}); + +test('trust device option persists a trusted device row', function () { + $user = User::factory()->create(); + Session::put('auth.2fa_user_id', $user->id); + Session::put('auth.2fa_code', '888888'); + Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp); + + $this->post('/2fa', ['code' => '888888', 'trust_device' => '1']); + + expect(UserTrustedDevice::where('user_id', $user->id)->count())->toBe(1); +}); + +test('trust device defaults to no row when option is unset', function () { + $user = User::factory()->create(); + Session::put('auth.2fa_user_id', $user->id); + Session::put('auth.2fa_code', '777777'); + Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp); + + $this->post('/2fa', ['code' => '777777']); + + expect(UserTrustedDevice::where('user_id', $user->id)->count())->toBe(0); +}); + +test('trusted device cookie skips 2fa view and auto-logs-in', function () { + $user = User::factory()->create(); + $deviceId = (string) Str::uuid(); + $secret = Str::random(64); + + UserTrustedDevice::create([ + 'user_id' => $user->id, + 'device_id' => $deviceId, + 'token' => hash('sha256', $secret), + 'expires_at' => now()->addDays(30), + ]); + + Session::put('auth.2fa_user_id', $user->id); + Session::put('auth.2fa_code', '000000'); + Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp); + + $this->withCookie('2fa_trust_device', $deviceId.'|'.$secret) + ->get('/2fa') + ->assertRedirect(route('dashboard', absolute: false)); + + $this->assertAuthenticatedAs($user); +}); + +test('trusted device cookie with wrong secret does not auto-login', function () { + $user = User::factory()->create(); + $deviceId = (string) Str::uuid(); + $realSecret = Str::random(64); + + UserTrustedDevice::create([ + 'user_id' => $user->id, + 'device_id' => $deviceId, + 'token' => hash('sha256', $realSecret), + 'expires_at' => now()->addDays(30), + ]); + + Session::put('auth.2fa_user_id', $user->id); + Session::put('auth.2fa_code', '000000'); + Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp); + + $this->withCookie('2fa_trust_device', $deviceId.'|wrong-secret') + ->get('/2fa') + ->assertOk(); + + $this->assertGuest(); +}); diff --git a/tests/Feature/Auth/WebAuthnConfigTest.php b/tests/Feature/Auth/WebAuthnConfigTest.php new file mode 100644 index 0000000..1e8f835 --- /dev/null +++ b/tests/Feature/Auth/WebAuthnConfigTest.php @@ -0,0 +1,51 @@ +getProperty('resolvedSettings'); + $prop->setAccessible(true); + $prop->setValue(null, null); + Cache::flush(); +}); + +test('WebAuthn login controller class exists and has required methods', function () { + $ref = new ReflectionClass(WebAuthnLoginController::class); + expect($ref->hasMethod('options'))->toBeTrue(); + expect($ref->hasMethod('login'))->toBeTrue(); +}); + +test('WebAuthn register controller class exists and has required methods', function () { + $ref = new ReflectionClass(WebAuthnRegisterController::class); + expect($ref->hasMethod('options'))->toBeTrue(); + expect($ref->hasMethod('register'))->toBeTrue(); +}); + +test('webauthn_enabled setting defaults to false in fresh DB', function () { + expect(get_setting('webauthn_enabled', false))->toBeFalse(); +}); + +test('webauthn_enabled setting can be toggled on', function () { + app(SystemConfigService::class)->update(['webauthn_enabled' => true]); + + expect(get_setting('webauthn_enabled', false))->toBeTrue(); +}); + +test('webauthn_credentials migration created the laragear table', function () { + expect(Schema::hasTable('webauthn_credentials'))->toBeTrue(); +}); + +test('webauthn_credentials table has the expected key columns', function () { + $cols = Schema::getColumnListing('webauthn_credentials'); + + foreach (['id', 'authenticatable_id', 'authenticatable_type'] as $required) { + expect($cols)->toContain($required); + } +}); diff --git a/tests/Feature/Database/CascadeIntegrityTest.php b/tests/Feature/Database/CascadeIntegrityTest.php new file mode 100644 index 0000000..48ead08 --- /dev/null +++ b/tests/Feature/Database/CascadeIntegrityTest.php @@ -0,0 +1,148 @@ +create(); + PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('a')]); + PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('b')]); + + expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(2); + + $user->forceDelete(); + + expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(0); +}); + +test('force deleting a user cascades user_consents', function () { + $user = User::factory()->create(); + UserConsent::create([ + 'user_id' => $user->id, + 'consent_type' => 'tos', + 'version_id' => 1, + 'ip_address' => '127.0.0.1', + ]); + + expect(UserConsent::where('user_id', $user->id)->count())->toBe(1); + + $user->forceDelete(); + + expect(UserConsent::where('user_id', $user->id)->count())->toBe(0); +}); + +test('force deleting a user cascades user_trusted_devices', function () { + $user = User::factory()->create(); + UserTrustedDevice::create([ + 'user_id' => $user->id, + 'device_id' => 'dev-1', + 'token' => 'tok', + 'expires_at' => now()->addDays(30), + ]); + + $user->forceDelete(); + + expect(UserTrustedDevice::where('user_id', $user->id)->count())->toBe(0); +}); + +test('force deleting a user nulls system_settings audit columns', function () { + $actor = User::factory()->create(); + DB::table('system_settings')->insert([ + 'key' => 'cascade_test', + 'value' => 'x', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => false, + 'created_by' => $actor->id, + 'updated_by' => $actor->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $actor->forceDelete(); + + $row = DB::table('system_settings')->where('key', 'cascade_test')->first(); + expect($row->created_by)->toBeNull(); + expect($row->updated_by)->toBeNull(); +}); + +test('force deleting a user nulls system_setting_revisions.changed_by', function () { + $actor = User::factory()->create(); + $settingId = DB::table('system_settings')->insertGetId([ + 'key' => 'cascade_test_2', + 'value' => 'x', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('system_setting_revisions')->insert([ + 'system_setting_id' => $settingId, + 'key' => 'cascade_test_2', + 'old_value' => null, + 'new_value' => '"x"', + 'changed_by' => $actor->id, + 'created_at' => now(), + ]); + + $actor->forceDelete(); + + expect(DB::table('system_setting_revisions')->where('key', 'cascade_test_2')->value('changed_by')) + ->toBeNull(); +}); + +test('deleting a role removes role_has_permissions linkage', function () { + $role = Role::create(['name' => 'cascade-role', 'guard_name' => 'web']); + $perm = Permission::firstOrCreate(['name' => 'cascade-perm', 'guard_name' => 'web']); + $role->givePermissionTo($perm); + + expect(DB::table('role_has_permissions')->where('role_id', $role->id)->count())->toBe(1); + + $role->forceDelete(); + + expect(DB::table('role_has_permissions')->where('role_id', $role->id)->count())->toBe(0); +}); + +test('deleting a permission removes role_has_permissions linkage', function () { + $role = Role::create(['name' => 'host-role', 'guard_name' => 'web']); + $perm = Permission::firstOrCreate(['name' => 'doomed-perm', 'guard_name' => 'web']); + $role->givePermissionTo($perm); + + $perm->forceDelete(); + + expect(DB::table('role_has_permissions')->where('permission_id', $perm->id)->count())->toBe(0); +}); + +test('roles audit columns null out when the actor is force-deleted', function () { + $actor = User::factory()->create(); + $role = Role::create(['name' => 'audit-role', 'guard_name' => 'web']); + DB::table('roles')->where('id', $role->id)->update([ + 'created_by' => $actor->id, + 'updated_by' => $actor->id, + ]); + + $actor->forceDelete(); + + $row = DB::table('roles')->where('id', $role->id)->first(); + expect($row->created_by)->toBeNull(); + expect($row->updated_by)->toBeNull(); +}); + +test('soft-deleting a user keeps related rows intact', function () { + $user = User::factory()->create(); + PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('a')]); + + $user->delete(); // soft + + expect(User::withTrashed()->find($user->id)->trashed())->toBeTrue(); + expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(1); +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php new file mode 100644 index 0000000..8b5843f --- /dev/null +++ b/tests/Feature/ExampleTest.php @@ -0,0 +1,7 @@ +get('/'); + + $response->assertStatus(200); +}); diff --git a/tests/Feature/Helpers/ApiResponseTest.php b/tests/Feature/Helpers/ApiResponseTest.php new file mode 100644 index 0000000..9bc6a8d --- /dev/null +++ b/tests/Feature/Helpers/ApiResponseTest.php @@ -0,0 +1,58 @@ + 1], 'OK'); + expect($r->getStatusCode())->toBe(200); + $body = $r->getData(true); + expect($body['status'])->toBe('success'); + expect($body['message'])->toBe('OK'); + expect($body['data'])->toBe(['x' => 1]); +}); + +test('success omits data key when data is null', function () { + $body = ApiResponse::success(null, 'no body')->getData(true); + expect($body)->not->toHaveKey('data'); +}); + +test('error returns the given code and message', function () { + $r = ApiResponse::error('Boom', 418); + expect($r->getStatusCode())->toBe(418); + $body = $r->getData(true); + expect($body['status'])->toBe('error'); + expect($body['message'])->toBe('Boom'); +}); + +test('error includes errors array when provided', function () { + $body = ApiResponse::error('Bad', 422, ['field' => 'is broken'])->getData(true); + expect($body['errors'])->toBe(['field' => 'is broken']); +}); + +test('created returns 201', function () { + expect(ApiResponse::created(['id' => 5])->getStatusCode())->toBe(201); +}); + +test('notFound returns 404 with default message', function () { + $r = ApiResponse::notFound(); + expect($r->getStatusCode())->toBe(404); + expect($r->getData(true)['message'])->toBe('Resource not found'); +}); + +test('unauthorized returns 401', function () { + expect(ApiResponse::unauthorized()->getStatusCode())->toBe(401); +}); + +test('forbidden returns 403', function () { + expect(ApiResponse::forbidden()->getStatusCode())->toBe(403); +}); + +test('validationError returns 422 with errors payload', function () { + $r = ApiResponse::validationError(['email' => ['required']]); + expect($r->getStatusCode())->toBe(422); + expect($r->getData(true)['errors'])->toBe(['email' => ['required']]); +}); + +test('serverError returns 500', function () { + expect(ApiResponse::serverError()->getStatusCode())->toBe(500); +}); diff --git a/tests/Feature/Helpers/PasswordRuleHelperTest.php b/tests/Feature/Helpers/PasswordRuleHelperTest.php new file mode 100644 index 0000000..4fe3f0a --- /dev/null +++ b/tests/Feature/Helpers/PasswordRuleHelperTest.php @@ -0,0 +1,65 @@ +toContain('required'); + expect($rules)->toContain('string'); + expect($rules)->toContain('min:12'); +}); + +test('rules array includes regex for each charset class', function () { + $rules = PasswordRuleHelper::rules(); + expect(implode('|', $rules)) + ->toContain('regex:/[a-z]/') + ->toContain('regex:/[A-Z]/') + ->toContain('regex:/[0-9]/'); +}); + +test('messages contain min and regex keys', function () { + $msgs = PasswordRuleHelper::messages(); + expect($msgs)->toHaveKeys(['password.min', 'password.regex']); +}); + +test('rules accept a strong password via Validator', function () { + $v = Validator::make( + ['password' => 'Str0ng!Passw0rd2026'], + ['password' => PasswordRuleHelper::rules()], + PasswordRuleHelper::messages(), + ); + expect($v->fails())->toBeFalse(); +}); + +test('rules reject short passwords', function () { + $v = Validator::make( + ['password' => 'Abc123!'], + ['password' => PasswordRuleHelper::rules()], + ); + expect($v->fails())->toBeTrue(); +}); + +test('rules reject passwords missing uppercase', function () { + $v = Validator::make( + ['password' => 'all_lower_123!'], + ['password' => PasswordRuleHelper::rules()], + ); + expect($v->fails())->toBeTrue(); +}); + +test('rules reject passwords missing digit', function () { + $v = Validator::make( + ['password' => 'NoDigitsHere!'], + ['password' => PasswordRuleHelper::rules()], + ); + expect($v->fails())->toBeTrue(); +}); + +test('rules reject passwords missing symbol', function () { + $v = Validator::make( + ['password' => 'NoSymbolsHere1'], + ['password' => PasswordRuleHelper::rules()], + ); + expect($v->fails())->toBeTrue(); +}); diff --git a/tests/Feature/ImpersonateTest.php b/tests/Feature/ImpersonateTest.php new file mode 100644 index 0000000..f2b1df2 --- /dev/null +++ b/tests/Feature/ImpersonateTest.php @@ -0,0 +1,102 @@ +create(['is_active' => true]); + $perm = Permission::firstOrCreate(['name' => 'impersonate users', 'guard_name' => 'web']); + $admin->givePermissionTo($perm); + + return $admin; +} + +test('guest cannot start impersonation', function () { + $target = User::factory()->create(); + + $this->post("/impersonate/{$target->id}")->assertRedirect('/login'); +}); + +test('user without permission cannot start impersonation', function () { + $admin = User::factory()->create(); + $target = User::factory()->create(); + + $this->actingAs($admin)->post("/impersonate/{$target->id}")->assertForbidden(); +}); + +test('cannot impersonate self', function () { + $admin = makeImpersonator(); + + $this->actingAs($admin) + ->post("/impersonate/{$admin->id}") + ->assertForbidden(); +}); + +test('cannot impersonate an inactive user', function () { + $admin = makeImpersonator(); + $target = User::factory()->create(['is_active' => false]); + + $this->actingAs($admin) + ->post("/impersonate/{$target->id}") + ->assertForbidden(); +}); + +test('cannot impersonate a Developer (Super Admin)', function () { + $admin = makeImpersonator(); + $dev = User::factory()->create(['is_active' => true]); + Role::firstOrCreate(['name' => 'Developer', 'guard_name' => 'web']); + $dev->assignRole('Developer'); + + $this->actingAs($admin) + ->post("/impersonate/{$dev->id}") + ->assertForbidden(); +}); + +test('start switches the authenticated user and stashes original id', function () { + $admin = makeImpersonator(); + $target = User::factory()->create(['is_active' => true]); + + $this->actingAs($admin) + ->post("/impersonate/{$target->id}") + ->assertRedirect(route('dashboard', absolute: false)) + ->assertSessionHas('impersonator_id', $admin->id); + + expect(auth()->id())->toBe($target->id); +}); + +test('cannot start a second impersonation while already impersonating', function () { + $admin = makeImpersonator(); + $first = User::factory()->create(['is_active' => true]); + $first->givePermissionTo('impersonate users'); // edge case: target also has permission + $second = User::factory()->create(['is_active' => true]); + + $this->actingAs($admin)->post("/impersonate/{$first->id}"); + + $this->post("/impersonate/{$second->id}") + ->assertRedirect() + ->assertSessionHas('error'); + + expect(auth()->id())->toBe($first->id); +}); + +test('stop without an active session returns 403', function () { + $admin = makeImpersonator(); + + $this->actingAs($admin)->post('/impersonate/stop')->assertForbidden(); +}); + +test('stop restores the original super admin', function () { + $admin = makeImpersonator(); + $target = User::factory()->create(['is_active' => true]); + + $this->actingAs($admin)->post("/impersonate/{$target->id}"); + expect(auth()->id())->toBe($target->id); + + $this->post('/impersonate/stop') + ->assertRedirect(route('users', absolute: false)); + + expect(auth()->id())->toBe($admin->id); + expect(session()->has('impersonator_id'))->toBeFalse(); +}); diff --git a/tests/Feature/Middleware/CheckActivePermissionTest.php b/tests/Feature/Middleware/CheckActivePermissionTest.php new file mode 100644 index 0000000..395fe57 --- /dev/null +++ b/tests/Feature/Middleware/CheckActivePermissionTest.php @@ -0,0 +1,46 @@ +get('/__probe', fn () => response('ok')) + ->name('test.probe'); +}); + +test('inactive permission returns 403 even when user has it', function () { + Permission::firstOrCreate(['name' => 'probe', 'guard_name' => 'web', 'is_active' => false]); + $user = User::factory()->create(); + $user->givePermissionTo('probe'); + + $this->actingAs($user)->get('/__probe')->assertForbidden(); +}); + +test('active permission allows the request through', function () { + Permission::firstOrCreate(['name' => 'probe', 'guard_name' => 'web', 'is_active' => true]); + $user = User::factory()->create(); + $user->givePermissionTo('probe'); + + $this->actingAs($user)->get('/__probe')->assertOk()->assertSeeText('ok'); +}); + +test('missing permission returns 403', function () { + $user = User::factory()->create(); + + $this->actingAs($user)->get('/__probe')->assertForbidden(); +}); + +test('cache is consulted on subsequent hits', function () { + Permission::firstOrCreate(['name' => 'probe', 'guard_name' => 'web', 'is_active' => true]); + $user = User::factory()->create(); + $user->givePermissionTo('probe'); + + $this->actingAs($user)->get('/__probe')->assertOk(); + + expect(Cache::has('permission_status:probe'))->toBeTrue(); +}); diff --git a/tests/Feature/Middleware/CheckLegalAgreementTest.php b/tests/Feature/Middleware/CheckLegalAgreementTest.php new file mode 100644 index 0000000..179d043 --- /dev/null +++ b/tests/Feature/Middleware/CheckLegalAgreementTest.php @@ -0,0 +1,61 @@ +withMiddleware(CheckLegalAgreement::class); + + $ref = new ReflectionClass(SystemConfigService::class); + $prop = $ref->getProperty('resolvedSettings'); + $prop->setAccessible(true); + $prop->setValue(null, null); + Cache::flush(); + + Route::middleware(['web', 'auth', CheckLegalAgreement::class]) + ->get('/__legal-probe', fn () => response('ok')); +}); + +function setLegalVersion(string $prefix, int $version): void +{ + app(SystemConfigService::class)->update(["{$prefix}_document_version" => $version]); +} + +test('guest is unaffected by middleware', function () { + $this->get('/__legal-probe')->assertRedirect('/login'); +}); + +test('user without consent is redirected to re-agree', function () { + setLegalVersion('tos', 1); + setLegalVersion('pdp', 1); + $user = User::factory()->create(); + + $this->actingAs($user)->get('/__legal-probe') + ->assertRedirect(route('legal.re-agree', absolute: false)); +}); + +test('user with current consent passes through', function () { + setLegalVersion('tos', 1); + setLegalVersion('pdp', 1); + $user = User::factory()->create(); + UserConsent::create(['user_id' => $user->id, 'consent_type' => 'tos', 'version_id' => 1, 'ip_address' => '127.0.0.1']); + UserConsent::create(['user_id' => $user->id, 'consent_type' => 'privacy', 'version_id' => 1, 'ip_address' => '127.0.0.1']); + + $this->actingAs($user)->get('/__legal-probe')->assertOk(); +}); + +test('user with outdated consent is redirected', function () { + setLegalVersion('tos', 2); + setLegalVersion('pdp', 2); + $user = User::factory()->create(); + UserConsent::create(['user_id' => $user->id, 'consent_type' => 'tos', 'version_id' => 1, 'ip_address' => '127.0.0.1']); + UserConsent::create(['user_id' => $user->id, 'consent_type' => 'privacy', 'version_id' => 1, 'ip_address' => '127.0.0.1']); + + $this->actingAs($user)->get('/__legal-probe') + ->assertRedirect(route('legal.re-agree', absolute: false)); +}); diff --git a/tests/Feature/Middleware/IpAccessControlTest.php b/tests/Feature/Middleware/IpAccessControlTest.php new file mode 100644 index 0000000..a3a04dc --- /dev/null +++ b/tests/Feature/Middleware/IpAccessControlTest.php @@ -0,0 +1,78 @@ +getProperty('resolvedSettings'); + $prop->setAccessible(true); + $prop->setValue(null, null); + Cache::flush(); + + Route::middleware([IpAccessControl::class]) + ->get('/__ip-probe', fn () => response('ok')) + ->name('test.ip-probe'); + + Route::middleware([IpAccessControl::class]) + ->get('/users/__ip-probe', fn () => response('ok-users')); +}); + +function setIpSetting(string $key, mixed $value): void +{ + app(SystemConfigService::class)->update([$key => $value]); +} + +test('request passes through with no IP rules configured', function () { + $this->get('/__ip-probe')->assertOk()->assertSeeText('ok'); +}); + +test('blacklisted IP gets 403', function () { + setIpSetting('ip_blacklist', '127.0.0.1, 10.0.0.5'); + + $this->get('/__ip-probe', ['REMOTE_ADDR' => '127.0.0.1'])->assertForbidden(); +}); + +test('non-blacklisted IP passes through', function () { + setIpSetting('ip_blacklist', '10.0.0.5'); + + $this->get('/__ip-probe', ['REMOTE_ADDR' => '127.0.0.1'])->assertOk(); +}); + +test('admin whitelist denies non-whitelisted IPs on admin routes', function () { + setIpSetting('ip_whitelist_admin', '203.0.113.1'); + + $this->call('GET', '/users/__ip-probe', server: ['REMOTE_ADDR' => '127.0.0.1'])->assertForbidden(); +}); + +test('admin whitelist permits whitelisted IPs on admin routes', function () { + setIpSetting('ip_whitelist_admin', '127.0.0.1'); + + $this->call('GET', '/users/__ip-probe', server: ['REMOTE_ADDR' => '127.0.0.1'])->assertOk(); +}); + +test('admin whitelist does not affect non-admin routes', function () { + setIpSetting('ip_whitelist_admin', '203.0.113.1'); + + $this->get('/__ip-probe', ['REMOTE_ADDR' => '127.0.0.1'])->assertOk(); +}); + +test('auto-blocked IP returns 429', function () { + setIpSetting('auto_block_ip', true); + Cache::put('ip_block:127.0.0.1', true, now()->addHour()); + + $this->get('/__ip-probe', ['REMOTE_ADDR' => '127.0.0.1'])->assertStatus(429); +}); + +test('single session enforcement logs out stale session', function () { + setIpSetting('session_single_session', true); + $user = User::factory()->create(['last_session_id' => 'OTHER_SESSION_ID']); + + $this->actingAs($user)->get('/__ip-probe') + ->assertRedirect(route('login', absolute: false)); + + $this->assertGuest(); +}); diff --git a/tests/Feature/Middleware/PasswordExpiryMiddlewareTest.php b/tests/Feature/Middleware/PasswordExpiryMiddlewareTest.php new file mode 100644 index 0000000..0808ce0 --- /dev/null +++ b/tests/Feature/Middleware/PasswordExpiryMiddlewareTest.php @@ -0,0 +1,57 @@ +getProperty('resolvedSettings'); + $prop->setAccessible(true); + $prop->setValue(null, null); + Cache::flush(); + + Route::middleware(['web', 'auth', PasswordExpiryMiddleware::class]) + ->get('/__pwd-probe', fn () => response('ok')); +}); + +function setExpirySetting(int $days): void +{ + app(SystemConfigService::class)->update(['password_expiry_days' => $days]); +} + +test('user with fresh password passes through', function () { + setExpirySetting(30); + $user = User::factory()->create(); + DB::table('users')->where('id', $user->id) + ->update(['password_changed_at' => now()->subDays(5)]); + + $this->actingAs($user->fresh())->get('/__pwd-probe')->assertOk(); +}); + +test('user with expired password is redirected to profile', function () { + setExpirySetting(30); + $user = User::factory()->create(); + DB::table('users')->where('id', $user->id) + ->update(['password_changed_at' => now()->subDays(40)]); + + $this->actingAs($user->fresh())->get('/__pwd-probe') + ->assertRedirect(route('profile.edit', absolute: false)) + ->assertSessionHas('warning'); +}); + +test('expiry disabled (0 days) never redirects', function () { + setExpirySetting(0); + $user = User::factory()->create(); + DB::table('users')->where('id', $user->id) + ->update(['password_changed_at' => now()->subYears(2)]); + + $this->actingAs($user->fresh())->get('/__pwd-probe')->assertOk(); +}); + +test('guest is unaffected', function () { + $this->get('/__pwd-probe')->assertRedirect('/login'); +}); diff --git a/tests/Feature/Middleware/SecurityHeadersTest.php b/tests/Feature/Middleware/SecurityHeadersTest.php new file mode 100644 index 0000000..4492011 --- /dev/null +++ b/tests/Feature/Middleware/SecurityHeadersTest.php @@ -0,0 +1,47 @@ +getProperty('resolvedSettings'); + $prop->setAccessible(true); + $prop->setValue(null, null); + Cache::flush(); + + Route::middleware('web') + ->get('/__sec-probe', fn () => response('ok')); +}); + +test('X-Content-Type-Options nosniff is present', function () { + $r = $this->get('/__sec-probe'); + expect($r->headers->get('X-Content-Type-Options'))->toBe('nosniff'); +}); + +test('X-Frame-Options SAMEORIGIN is present', function () { + $r = $this->get('/__sec-probe'); + expect($r->headers->get('X-Frame-Options'))->toBe('SAMEORIGIN'); +}); + +test('Referrer-Policy is strict-origin-when-cross-origin', function () { + $r = $this->get('/__sec-probe'); + expect($r->headers->get('Referrer-Policy'))->toBe('strict-origin-when-cross-origin'); +}); + +test('Permissions-Policy locks down camera, microphone, geolocation', function () { + $r = $this->get('/__sec-probe'); + $pp = $r->headers->get('Permissions-Policy'); + expect($pp)->toContain('camera=()')->toContain('microphone=()')->toContain('geolocation=()'); +}); + +test('X-XSS-Protection header is set', function () { + $r = $this->get('/__sec-probe'); + expect($r->headers->get('X-XSS-Protection'))->not->toBeNull(); +}); + +test('HSTS is omitted over plain HTTP regardless of setting', function () { + $r = $this->get('/__sec-probe'); + expect($r->headers->get('Strict-Transport-Security'))->toBeNull(); +}); diff --git a/tests/Feature/MobileConfigTest.php b/tests/Feature/MobileConfigTest.php new file mode 100644 index 0000000..711ccd8 --- /dev/null +++ b/tests/Feature/MobileConfigTest.php @@ -0,0 +1,34 @@ +getJson('/api/v1/mobile/sync'); + + $response->assertOk() + ->assertJsonStructure(['status', 'version', 'last_updated', 'data']); + + expect($response->json('status'))->toBe('success'); + expect($response->json('data'))->toBeArray()->not->toBeEmpty(); +}); + +test('mobile sync responds with ETag header', function () { + $response = $this->getJson('/api/v1/mobile/sync'); + + $response->assertOk(); + expect($response->headers->get('ETag'))->not->toBeNull(); +}); + +test('mobile sync returns 304 when If-None-Match matches', function () { + $etag = $this->getJson('/api/v1/mobile/sync')->headers->get('ETag'); + + $this->withHeaders(['If-None-Match' => $etag]) + ->getJson('/api/v1/mobile/sync') + ->assertStatus(304); +}); + +test('mobile sync is cached', function () { + $this->getJson('/api/v1/mobile/sync'); + + expect(Cache::has('mobile_config_all'))->toBeTrue(); +}); diff --git a/tests/Feature/Models/PrunableModelsTest.php b/tests/Feature/Models/PrunableModelsTest.php new file mode 100644 index 0000000..9944405 --- /dev/null +++ b/tests/Feature/Models/PrunableModelsTest.php @@ -0,0 +1,180 @@ + 'a@a.com', 'code' => '111111', 'expires_at' => now()->subMinute()]); + OtpCode::create(['identifier' => 'b@b.com', 'code' => '222222', 'expires_at' => now()->addHour()]); + + $prunableIds = (new OtpCode)->prunable()->pluck('id'); + + expect($prunableIds)->toHaveCount(1); +}); + +test('OtpCode prunable returns a Builder instance', function () { + expect((new OtpCode)->prunable())->toBeInstanceOf(Builder::class); +}); + +// ── UserTrustedDevice ───────────────────────────────────────────────────────── + +test('UserTrustedDevice prunable selects expired devices only', function () { + $user = User::factory()->create(); + + UserTrustedDevice::create([ + 'user_id' => $user->id, + 'device_id' => 'old-device', + 'token' => 'tok1', + 'expires_at' => now()->subDay(), + ]); + UserTrustedDevice::create([ + 'user_id' => $user->id, + 'device_id' => 'fresh-device', + 'token' => 'tok2', + 'expires_at' => now()->addDays(30), + ]); + + $ids = (new UserTrustedDevice)->prunable()->pluck('id'); + + expect($ids)->toHaveCount(1); +}); + +test('UserTrustedDevice prunable returns a Builder instance', function () { + expect((new UserTrustedDevice)->prunable())->toBeInstanceOf(Builder::class); +}); + +// ── PasswordHistory ─────────────────────────────────────────────────────────── + +test('PasswordHistory prunable selects records older than 365 days', function () { + $user = User::factory()->create(); + + \DB::table('password_histories')->insert([ + ['user_id' => $user->id, 'password' => 'h1', 'created_at' => now()->subDays(400), 'updated_at' => now()], + ['user_id' => $user->id, 'password' => 'h2', 'created_at' => now()->subDays(100), 'updated_at' => now()], + ]); + + $ids = (new PasswordHistory)->prunable()->pluck('id'); + + expect($ids)->toHaveCount(1); +}); + +test('PasswordHistory prunable returns a Builder instance', function () { + expect((new PasswordHistory)->prunable())->toBeInstanceOf(Builder::class); +}); + +// ── MobileSyncLog ───────────────────────────────────────────────────────────── + +test('MobileSyncLog prunable selects records older than 30 days', function () { + \DB::table('mobile_sync_logs')->insert([ + ['synced_at' => now()->subDays(35)], + ['synced_at' => now()->subDays(10)], + ]); + + $ids = (new MobileSyncLog)->prunable()->pluck('id'); + + expect($ids)->toHaveCount(1); +}); + +test('MobileSyncLog prunable returns a Builder instance', function () { + expect((new MobileSyncLog)->prunable())->toBeInstanceOf(Builder::class); +}); + +// ── MobileErrorLog ──────────────────────────────────────────────────────────── + +test('MobileErrorLog prunable selects records older than 90 days', function () { + \DB::table('mobile_error_logs')->insert([ + ['message' => 'old error', 'occurred_at' => now()->subDays(100)], + ['message' => 'new error', 'occurred_at' => now()->subDays(5)], + ]); + + $ids = (new MobileErrorLog)->prunable()->pluck('id'); + + expect($ids)->toHaveCount(1); +}); + +test('MobileErrorLog prunable returns a Builder instance', function () { + expect((new MobileErrorLog)->prunable())->toBeInstanceOf(Builder::class); +}); + +// ── Notification (system_notifications) ────────────────────────────────────── + +test('Notification prunable selects records older than 30 days', function () { + \DB::table('system_notifications')->insert([ + ['title' => 'old', 'message' => 'x', 'recipient' => 'all', 'type' => 'info', 'created_at' => now()->subDays(40), 'updated_at' => now()], + ['title' => 'new', 'message' => 'y', 'recipient' => 'all', 'type' => 'info', 'created_at' => now()->subDays(5), 'updated_at' => now()], + ]); + + $ids = (new Notification)->prunable()->pluck('id'); + + expect($ids)->toHaveCount(1); +}); + +test('Notification prunable returns a Builder instance', function () { + expect((new Notification)->prunable())->toBeInstanceOf(Builder::class); +}); + +// ── AiHealingLog ────────────────────────────────────────────────────────────── + +test('AiHealingLog prunable selects records older than 90 days', function () { + \DB::table('ai_healing_logs')->insert([ + ['error_message' => 'old', 'created_at' => now()->subDays(100), 'updated_at' => now()], + ['error_message' => 'new', 'created_at' => now()->subDays(10), 'updated_at' => now()], + ]); + + $ids = (new AiHealingLog)->prunable()->pluck('id'); + + expect($ids)->toHaveCount(1); +}); + +test('AiHealingLog prunable returns a Builder instance', function () { + expect((new AiHealingLog)->prunable())->toBeInstanceOf(Builder::class); +}); + +// ── AiUsageLog ──────────────────────────────────────────────────────────────── + +test('AiUsageLog prunable selects records older than 3 months', function () { + \DB::table('ai_usage_logs')->insert([ + ['provider' => 'openai', 'model' => 'gpt-4', 'status' => 'success', 'created_at' => now()->subMonths(4), 'updated_at' => now()], + ['provider' => 'openai', 'model' => 'gpt-4', 'status' => 'success', 'created_at' => now()->subWeek(), 'updated_at' => now()], + ]); + + $ids = (new AiUsageLog)->prunable()->pluck('id'); + + expect($ids)->toHaveCount(1); +}); + +// ── Edge cases ──────────────────────────────────────────────────────────────── + +test('OtpCode prunable returns empty when all codes are still valid', function () { + OtpCode::create(['identifier' => 'c@c.com', 'code' => '333333', 'expires_at' => now()->addHour()]); + + $ids = (new OtpCode)->prunable()->pluck('id'); + + expect($ids)->toBeEmpty(); +}); + +test('UserTrustedDevice prunable returns empty when no devices are expired', function () { + $user = User::factory()->create(); + UserTrustedDevice::create([ + 'user_id' => $user->id, + 'device_id' => 'good-device', + 'token' => 'tok3', + 'expires_at' => now()->addDays(7), + ]); + + $ids = (new UserTrustedDevice)->prunable()->pluck('id'); + + expect($ids)->toBeEmpty(); +}); diff --git a/tests/Feature/Performance/NPlusOneTest.php b/tests/Feature/Performance/NPlusOneTest.php new file mode 100644 index 0000000..f226b4d --- /dev/null +++ b/tests/Feature/Performance/NPlusOneTest.php @@ -0,0 +1,83 @@ + $name, 'guard_name' => 'web']); + } + $user->givePermissionTo(['view access rights', 'manage access rights', 'view user directory', 'manage user directory']); +} + +function queryCountDuring(callable $fn): int +{ + DB::enableQueryLog(); + DB::flushQueryLog(); + $fn(); + $count = count(DB::getQueryLog()); + DB::disableQueryLog(); + + return $count; +} + +test('users datatable query count is bounded regardless of row count', function () { + $admin = User::factory()->create(); + grantViewAndManageAccessRights($admin); + User::factory()->count(5)->create()->each(function ($u) { + $u->assignRole(Role::firstOrCreate(['name' => 'member-'.$u->id, 'guard_name' => 'web'])->name); + }); + + $baselineCount = queryCountDuring(function () use ($admin) { + $this->actingAs($admin)->getJson('/users?draw=1&start=0&length=10&columns[0][data]=id'); + }); + + User::factory()->count(20)->create()->each(function ($u) { + $u->assignRole(Role::firstOrCreate(['name' => 'member-'.$u->id, 'guard_name' => 'web'])->name); + }); + + $scaledCount = queryCountDuring(function () use ($admin) { + $this->actingAs($admin)->getJson('/users?draw=1&start=0&length=10&columns[0][data]=id'); + }); + + // 4x more rows should not 4x the query count — eager loading caps it. + expect($scaledCount - $baselineCount)->toBeLessThan(10); +}); + +test('roles datatable eagerly loads permissions and creator', function () { + $admin = User::factory()->create(); + grantViewAndManageAccessRights($admin); + + foreach (range(1, 5) as $i) { + $r = Role::create(['name' => 'np1-role-'.$i, 'guard_name' => 'web']); + $p = Permission::firstOrCreate(['name' => 'np1-perm-'.$i, 'guard_name' => 'web']); + $r->givePermissionTo($p); + } + + $count = queryCountDuring(function () use ($admin) { + $this->actingAs($admin)->getJson('/roles?draw=1&start=0&length=10&columns[0][data]=id'); + }); + + // Should not run a permission lookup per row. + expect($count)->toBeLessThan(20); +}); + +test('permissions datatable eagerly loads roles and creator', function () { + $admin = User::factory()->create(); + grantViewAndManageAccessRights($admin); + + foreach (range(1, 8) as $i) { + Permission::firstOrCreate(['name' => 'eager-perm-'.$i, 'guard_name' => 'web']); + } + + $count = queryCountDuring(function () use ($admin) { + $this->actingAs($admin)->getJson('/permissions?draw=1&start=0&length=10&columns[0][data]=id'); + }); + + expect($count)->toBeLessThan(20); +}); diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php new file mode 100644 index 0000000..f62caf8 --- /dev/null +++ b/tests/Feature/ProfileTest.php @@ -0,0 +1,86 @@ +create(); + + $response = $this + ->actingAs($user) + ->get('/profile'); + + $response->assertOk(); +}); + +test('profile information can be updated', function () { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch('/profile', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $user->refresh(); + + $this->assertSame('Test User', $user->name); + $this->assertSame('test@example.com', $user->email); + $this->assertNull($user->email_verified_at); +}); + +test('email verification status is unchanged when the email address is unchanged', function () { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch('/profile', [ + 'name' => 'Test User', + 'email' => $user->email, + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $this->assertNotNull($user->refresh()->email_verified_at); +}); + +test('user can delete their account', function () { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->delete('/profile', [ + 'password' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/'); + + $this->assertGuest(); + // User has SoftDeletes — deleted_at is set, not hard-deleted + $this->assertNotNull($user->fresh()->deleted_at); +}); + +test('correct password must be provided to delete account', function () { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->delete('/profile', [ + 'password' => 'wrong-password', + ]); + + $response + ->assertSessionHasErrorsIn('userDeletion', 'password') + ->assertRedirect('/profile'); + + $this->assertNotNull($user->fresh()); +}); diff --git a/tests/Feature/RateLimitTest.php b/tests/Feature/RateLimitTest.php new file mode 100644 index 0000000..80bac75 --- /dev/null +++ b/tests/Feature/RateLimitTest.php @@ -0,0 +1,78 @@ +withMiddleware(ThrottleRequests::class); + RateLimiter::clear('login'); +}); + +test('API login throttle eventually blocks after enough failed attempts', function () { + // The login endpoint stacks two protections: middleware throttle:10,1 and a + // controller-level RateLimiter keyed by IP+email (configurable via + // security_auth.login_max_attempts, default 5). After enough hits, one of + // them must kick in and return 429. + $sawBlock = false; + for ($i = 0; $i < 15; $i++) { + $r = $this->postJson('/api/v1/login', ['email' => 'x@x.com', 'password' => 'wrong']); + if ($r->getStatusCode() === 429) { + $sawBlock = true; + break; + } + } + expect($sawBlock)->toBeTrue(); +}); + +test('API forgot-password throttle blocks after 5 requests', function () { + for ($i = 0; $i < 5; $i++) { + $this->postJson('/api/v1/forgot-password', ['email' => 'x@x.com']); + } + + $this->postJson('/api/v1/forgot-password', ['email' => 'x@x.com']) + ->assertStatus(429); +}); + +test('API OTP send throttle blocks after 5 requests', function () { + for ($i = 0; $i < 5; $i++) { + $this->postJson('/api/v1/otp/send', ['email' => 'x@x.com']); + } + + $this->postJson('/api/v1/otp/send', ['email' => 'x@x.com']) + ->assertStatus(429); +}); + +test('API register throttle blocks after 5 requests', function () { + for ($i = 0; $i < 5; $i++) { + $this->postJson('/api/v1/register', []); + } + + $this->postJson('/api/v1/register', [])->assertStatus(429); +}); + +test('2FA verify throttle blocks after 5 requests', function () { + for ($i = 0; $i < 5; $i++) { + $this->post('/2fa', ['code' => '123456']); + } + + $this->post('/2fa', ['code' => '123456'])->assertStatus(429); +}); + +test('different IPs do not share the same rate-limit bucket', function () { + for ($i = 0; $i < 5; $i++) { + $this->call('POST', '/api/v1/forgot-password', + parameters: ['email' => 'x@x.com'], + server: ['REMOTE_ADDR' => '10.0.0.1'] + ); + } + + // Same email, different IP — should be fine + $r = $this->call('POST', '/api/v1/forgot-password', + parameters: ['email' => 'x@x.com'], + server: ['REMOTE_ADDR' => '10.0.0.2'] + ); + expect($r->getStatusCode())->not->toBe(429); +}); diff --git a/tests/Feature/Services/Auth/PasswordPolicyServiceTest.php b/tests/Feature/Services/Auth/PasswordPolicyServiceTest.php new file mode 100644 index 0000000..9d98334 --- /dev/null +++ b/tests/Feature/Services/Auth/PasswordPolicyServiceTest.php @@ -0,0 +1,110 @@ +getProperty('resolvedSettings'); + $prop->setAccessible(true); + $prop->setValue(null, null); + Cache::flush(); +}); + +function setSetting(string $key, mixed $value): void +{ + app(SystemConfigService::class)->update([$key => $value]); +} + +test('isPasswordExpired returns false when expiry is disabled', function () { + setSetting('password_expiry_days', 0); + $user = User::factory()->create([ + 'password_changed_at' => now()->subYears(5), + ]); + + expect(PasswordPolicyService::isPasswordExpired($user))->toBeFalse(); +}); + +test('isPasswordExpired uses password_changed_at when present', function () { + setSetting('password_expiry_days', 30); + $expired = User::factory()->create(['password_changed_at' => now()->subDays(31)]); + $fresh = User::factory()->create(['password_changed_at' => now()->subDays(5)]); + + expect(PasswordPolicyService::isPasswordExpired($expired))->toBeTrue(); + expect(PasswordPolicyService::isPasswordExpired($fresh))->toBeFalse(); +}); + +test('isPasswordExpired falls back to created_at when password_changed_at is null', function () { + setSetting('password_expiry_days', 30); + $user = User::factory()->create(); + DB::table('users')->where('id', $user->id)->update([ + 'password_changed_at' => null, + 'created_at' => now()->subDays(40), + ]); + + expect(PasswordPolicyService::isPasswordExpired($user->fresh()))->toBeTrue(); +}); + +test('checkHistory is a no-op when history count is zero', function () { + setSetting('password_history_count', 0); + $user = User::factory()->create(); + PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('old-pass')]); + + PasswordPolicyService::checkHistory($user, 'old-pass'); +})->throwsNoExceptions(); + +test('checkHistory throws when reusing a recent password', function () { + setSetting('password_history_count', 3); + $user = User::factory()->create(); + PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('reused-pass')]); + + PasswordPolicyService::checkHistory($user, 'reused-pass'); +})->throws(ValidationException::class); + +test('checkHistory passes when new password is different from history', function () { + setSetting('password_history_count', 3); + $user = User::factory()->create(); + PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('old-1')]); + PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('old-2')]); + + PasswordPolicyService::checkHistory($user, 'totally-different'); +})->throwsNoExceptions(); + +test('checkHistory only inspects the most recent N entries', function () { + setSetting('password_history_count', 2); + $user = User::factory()->create(); + + DB::table('password_histories')->insert([ + ['user_id' => $user->id, 'password' => Hash::make('ancient'), 'created_at' => now()->subDays(10), 'updated_at' => now()->subDays(10)], + ['user_id' => $user->id, 'password' => Hash::make('recent-1'), 'created_at' => now()->subDays(2), 'updated_at' => now()->subDays(2)], + ['user_id' => $user->id, 'password' => Hash::make('recent-2'), 'created_at' => now()->subDay(), 'updated_at' => now()->subDay()], + ]); + + PasswordPolicyService::checkHistory($user, 'ancient'); +})->throwsNoExceptions(); + +test('recordPasswordChange creates history row and stamps password_changed_at', function () { + setSetting('password_history_count', 5); + $user = User::factory()->create(['password_changed_at' => null]); + + PasswordPolicyService::recordPasswordChange($user, Hash::make('new-pass')); + + expect($user->fresh()->password_changed_at)->not->toBeNull(); + expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(1); +}); + +test('getRules respects min/max length from settings', function () { + setSetting('password_min_length', 10); + setSetting('password_max_length', 50); + + $rules = PasswordPolicyService::getRules(); + + expect($rules)->toBeInstanceOf(Password::class); +}); diff --git a/tests/Feature/Services/System/BackupManagementServiceTest.php b/tests/Feature/Services/System/BackupManagementServiceTest.php new file mode 100644 index 0000000..0385675 --- /dev/null +++ b/tests/Feature/Services/System/BackupManagementServiceTest.php @@ -0,0 +1,78 @@ +service = app(BackupManagementService::class); +}); + +test('checkRequirements returns ok shape with binary status', function () { + $result = $this->service->checkRequirements(); + + expect($result)->toHaveKey('status'); + expect($result['status'])->toBeBool(); + + if (isset($result['binary'])) { + expect($result['binary'])->toBeIn(['pg_dump', 'mysqldump']); + } +}); + +test('testConnection on local disk succeeds', function () { + // Storage::fake may fail in Docker containers with restricted permissions + try { + Storage::fake('local'); + } catch (UnableToCreateDirectory $e) { + $this->markTestSkipped('Cannot create fake disk in this environment: '.$e->getMessage()); + } + Config::set('backup.backup.destination.disks', ['local']); + + $result = $this->service->testConnection(); + + expect($result['success'])->toBeTrue(); + expect($result['message'])->toContain('Successfully'); +}); + +test('parseBytes round-trips through formatBytes for whole units', function () { + $ref = new ReflectionMethod(BackupManagementService::class, 'parseBytes'); + $ref->setAccessible(true); + + expect($ref->invoke($this->service, '1 KB'))->toBe(1024.0); + expect($ref->invoke($this->service, '1 MB'))->toBe(1048576.0); + expect($ref->invoke($this->service, '2 GB'))->toBe(2.0 * 1024 * 1024 * 1024); + expect($ref->invoke($this->service, '512 B'))->toBe(512.0); +}); + +test('parseBytes returns raw float when no unit suffix', function () { + $ref = new ReflectionMethod(BackupManagementService::class, 'parseBytes'); + $ref->setAccessible(true); + + expect($ref->invoke($this->service, '4096'))->toBe(4096.0); +}); + +test('parseBytes ignores unknown units', function () { + $ref = new ReflectionMethod(BackupManagementService::class, 'parseBytes'); + $ref->setAccessible(true); + + expect($ref->invoke($this->service, '10 XB'))->toBe(10.0); +}); + +test('parseBytes handles fractional values', function () { + $ref = new ReflectionMethod(BackupManagementService::class, 'parseBytes'); + $ref->setAccessible(true); + + expect($ref->invoke($this->service, '1.5 KB'))->toBe(1536.0); +}); + +test('formatBytes private helper renders KB/MB/GB units', function () { + $ref = new ReflectionMethod(BackupManagementService::class, 'formatBytes'); + $ref->setAccessible(true); + + expect($ref->invoke($this->service, 1024))->toBe('1 KB'); + expect($ref->invoke($this->service, 1024 * 1024))->toBe('1 MB'); + expect($ref->invoke($this->service, 1024 * 1024 * 1024))->toBe('1 GB'); +}); diff --git a/tests/Feature/Services/SystemConfig/SystemConfigServiceTest.php b/tests/Feature/Services/SystemConfig/SystemConfigServiceTest.php new file mode 100644 index 0000000..f91414c --- /dev/null +++ b/tests/Feature/Services/SystemConfig/SystemConfigServiceTest.php @@ -0,0 +1,167 @@ +getProperty('resolvedSettings'); + $prop->setAccessible(true); + $prop->setValue(null, null); + }; + $reset(); + Cache::flush(); + $this->service = app(SystemConfigService::class); +}); + +test('definitions returns non-empty array with expected meta keys', function () { + $defs = SystemConfigService::definitions(); + + expect($defs)->toBeArray()->not->toBeEmpty(); + expect($defs)->toHaveKey('app_name'); + + foreach ($defs as $key => $meta) { + expect($meta)->toHaveKeys(['type', 'group', 'is_public']); + } +}); + +test('all returns definition defaults when DB is empty', function () { + $all = $this->service->all(); + + expect($all['app_name'])->toBe('Laravel'); + expect($all['regional_timezone'])->toBe('Asia/Jakarta'); +}); + +test('all returns DB values when row exists', function () { + SystemSetting::create([ + 'key' => 'app_name', + 'value' => 'CustomApp', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + ]); + + $all = $this->service->all(forceRefresh: true); + + expect($all['app_name'])->toBe('CustomApp'); +}); + +test('get returns default for unknown key', function () { + expect($this->service->get('nonexistent_key', 'fallback'))->toBe('fallback'); +}); + +test('get returns value for known key', function () { + expect($this->service->get('app_name'))->toBe('Laravel'); +}); + +test('getPublicSettings only includes is_public=true keys', function () { + $public = $this->service->getPublicSettings(); + + expect($public)->toHaveKey('app_name'); + + foreach ($public as $key => $value) { + $meta = SystemConfigService::definitions()[$key]; + expect($meta['is_public'])->toBeTrue(); + } +}); + +test('grouped returns settings keyed by group', function () { + $grouped = $this->service->grouped(); + + expect($grouped)->toHaveKey('branding'); + expect($grouped['branding'])->toHaveKey('app_name'); +}); + +test('update creates new setting row', function () { + $this->service->update(['app_name' => 'Updated']); + + $this->assertDatabaseHas('system_settings', [ + 'key' => 'app_name', + 'value' => 'Updated', + ]); +}); + +test('update overwrites existing setting row', function () { + SystemSetting::create([ + 'key' => 'app_name', + 'value' => 'Old', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + ]); + + $this->service->update(['app_name' => 'New']); + + expect(SystemSetting::where('key', 'app_name')->count())->toBe(1); + expect(SystemSetting::where('key', 'app_name')->first()->value)->toBe('New'); +}); + +test('update writes a revision row', function () { + $user = User::factory()->create(); + + $this->service->update(['app_name' => 'Revisioned'], actorId: $user->id); + + $rev = SystemSettingRevision::where('key', 'app_name')->first(); + + expect($rev)->not->toBeNull(); + expect($rev->changed_by)->toBe($user->id); + expect($rev->new_value)->toContain('Revisioned'); +}); + +test('update does not write revision when value unchanged', function () { + SystemSetting::create([ + 'key' => 'app_name', + 'value' => 'Same', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + ]); + + $this->service->update(['app_name' => 'Same']); + + expect(SystemSettingRevision::where('key', 'app_name')->count())->toBe(0); +}); + +test('update serializes bool values to 1 or 0', function () { + $this->service->update(['enable_landing_page' => false]); + + $row = SystemSetting::where('key', 'enable_landing_page')->first(); + expect($row->value)->toBe('0'); + + $this->service->update(['enable_landing_page' => true]); + $row->refresh(); + expect($row->value)->toBe('1'); +}); + +test('update clears the cache after writing', function () { + Cache::put('system_settings.all', ['app_name' => 'StaleCached'], 60); + + $this->service->update(['app_name' => 'Fresh']); + + expect(Cache::has('system_settings.all'))->toBeFalse(); +}); + +test('update normalizes bool input from string', function () { + $this->service->update(['enable_landing_page' => 'false']); + + $row = SystemSetting::where('key', 'enable_landing_page')->first(); + expect($row->value)->toBe('0'); +}); + +test('update records request IP and user agent in revision', function () { + $request = Request::create('/system-settings', 'POST', server: [ + 'REMOTE_ADDR' => '203.0.113.7', + 'HTTP_USER_AGENT' => 'pest-test-agent', + ]); + + $this->service->update(['app_name' => 'Tracked'], request: $request); + + $rev = SystemSettingRevision::where('key', 'app_name')->first(); + expect($rev->changed_ip)->toBe('203.0.113.7'); + expect($rev->changed_agent)->toBe('pest-test-agent'); +}); diff --git a/tests/Feature/System/AiCircuitBreakerTest.php b/tests/Feature/System/AiCircuitBreakerTest.php new file mode 100644 index 0000000..754c654 --- /dev/null +++ b/tests/Feature/System/AiCircuitBreakerTest.php @@ -0,0 +1,48 @@ +seed(\Database\Seeders\RoleAndPermissionSeeder::class); + } + + public function test_circuit_breaker_blocks_excessive_healing_attempts() + { + // 1. Set limit to 2 fixes per hour + $config = app(SystemConfigService::class); + $config->update(['ai_healing_max_attempts_per_hour' => 2]); + + // 2. Create 2 recent "resolved" logs + AiHealingLog::factory()->count(2)->create([ + 'status' => 'resolved', + 'created_at' => now()->subMinutes(10), + ]); + + // 3. Verify count + $this->assertEquals(2, AiHealingLog::where('created_at', '>', now()->subHour())->count()); + + // 4. Simulate a new exception (we need to trigger the logic in bootstrap/app.php or simulate it) + // Since we can't easily trigger bootstrap/app.php in a feature test without throwing a real exception, + // we can test the logic directly or via a mock. + + // Let's test if the circuit breaker logic would block. + $maxPerHour = (int) $config->get('ai_healing_max_attempts_per_hour', 5); + $recentCount = AiHealingLog::where('created_at', '>', now()->subHour()) + ->whereIn('status', ['resolved', 'pending', 'diagnosing']) + ->count(); + + $this->assertTrue($recentCount >= $maxPerHour, "Circuit breaker should be tripped"); + } +} diff --git a/tests/Feature/System/EditorUploadTest.php b/tests/Feature/System/EditorUploadTest.php new file mode 100644 index 0000000..d4710c5 --- /dev/null +++ b/tests/Feature/System/EditorUploadTest.php @@ -0,0 +1,137 @@ +create(); + $perm = Permission::firstOrCreate(['name' => 'manage global settings', 'guard_name' => 'web']); + $user->givePermissionTo($perm); + + return $user; +} + +beforeEach(function () { + Storage::fake('public'); +}); + +// ── Access control ───────────────────────────────────────────────────────────── + +test('guest cannot upload editor images', function () { + $this->postJson('/editor/upload')->assertUnauthorized(); +}); + +test('user without manage global settings cannot upload', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->postJson('/editor/upload') + ->assertForbidden(); +}); + +// ── Missing file ─────────────────────────────────────────────────────────────── + +test('upload without file returns 400', function () { + $user = makeEditorUser(); + + $this->actingAs($user) + ->postJson('/editor/upload') + ->assertStatus(400) + ->assertJsonPath('error.message', 'No file uploaded.'); +}); + +// ── Type validation ──────────────────────────────────────────────────────────── + +test('upload rejects non-image file', function () { + $user = makeEditorUser(); + $file = UploadedFile::fake()->create('shell.php', 100, 'application/x-php'); + + $this->actingAs($user) + ->postJson('/editor/upload', ['upload' => $file]) + ->assertStatus(422); +}); + +test('upload rejects svg (not in allowed mimes)', function () { + $user = makeEditorUser(); + $file = UploadedFile::fake()->create('vector.svg', 50, 'image/svg+xml'); + + $this->actingAs($user) + ->postJson('/editor/upload', ['upload' => $file]) + ->assertStatus(422); +}); + +test('upload accepts jpeg image', function () { + $user = makeEditorUser(); + $file = UploadedFile::fake()->image('photo.jpg', 800, 600); + + $this->actingAs($user) + ->post('/editor/upload', ['upload' => $file]) + ->assertOk() + ->assertJsonPath('uploaded', 1) + ->assertJsonStructure(['url', 'fileName']); +}); + +test('upload accepts png image', function () { + $user = makeEditorUser(); + $file = UploadedFile::fake()->image('icon.png', 100, 100); + + $this->actingAs($user) + ->post('/editor/upload', ['upload' => $file]) + ->assertOk() + ->assertJsonPath('uploaded', 1); +}); + +test('upload accepts webp image', function () { + $user = makeEditorUser(); + $file = UploadedFile::fake()->image('modern.webp', 400, 300); + + $this->actingAs($user) + ->post('/editor/upload', ['upload' => $file]) + ->assertOk() + ->assertJsonPath('uploaded', 1); +}); + +// ── Size limit ───────────────────────────────────────────────────────────────── + +test('upload rejects image exceeding 5 MB', function () { + $user = makeEditorUser(); + $file = UploadedFile::fake()->create('big.jpg', 6000, 'image/jpeg'); // 6 MB + + $this->actingAs($user) + ->postJson('/editor/upload', ['upload' => $file]) + ->assertStatus(422); +}); + +// ── Response structure ───────────────────────────────────────────────────────── + +test('successful upload response has uploaded, fileName, url keys', function () { + $user = makeEditorUser(); + $file = UploadedFile::fake()->image('test.jpg'); + + $response = $this->actingAs($user) + ->post('/editor/upload', ['upload' => $file]) + ->assertOk() + ->json(); + + expect($response)->toHaveKeys(['uploaded', 'fileName', 'url']); + expect($response['uploaded'])->toBe(1); + expect($response['url'])->toStartWith('/storage/'); +}); + +// ── File is actually stored ──────────────────────────────────────────────────── + +test('uploaded file is persisted to the public disk under editor/', function () { + $user = makeEditorUser(); + $file = UploadedFile::fake()->image('stored.jpg'); + + $response = $this->actingAs($user) + ->post('/editor/upload', ['upload' => $file]) + ->json(); + + // Strip /storage/ prefix to get the relative path on the public disk + $relativePath = substr((string) $response['url'], strlen('/storage/')); + Storage::disk('public')->assertExists($relativePath); +}); diff --git a/tests/Feature/System/NotificationCenterTest.php b/tests/Feature/System/NotificationCenterTest.php new file mode 100644 index 0000000..56bfecc --- /dev/null +++ b/tests/Feature/System/NotificationCenterTest.php @@ -0,0 +1,244 @@ +getProperty('resolvedSettings'); + $prop->setAccessible(true); + $prop->setValue(null, null); + Cache::flush(); +}); + +function makeNotificationAdmin(): User +{ + $role = Role::firstOrCreate(['name' => 'Developer', 'guard_name' => 'web']); + $user = User::factory()->create(); + $user->assignRole($role); + + foreach (['view notification center', 'manage notification center'] as $name) { + $perm = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']); + $user->givePermissionTo($perm); + } + + return $user; +} + +function makeNotificationViewer(): User +{ + $user = User::factory()->create(); + $perm = Permission::firstOrCreate(['name' => 'view notification center', 'guard_name' => 'web']); + $user->givePermissionTo($perm); + + return $user; +} + +function makeNotificationForUser(User $user): Notification +{ + $role = Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']); + + $notification = Notification::create([ + 'title' => 'Test Notice', + 'message' => 'Hello', + 'recipient' => 'all', + 'type' => 'info', + 'created_by' => $user->id, + ]); + + // Attach pivot row so the user sees it + \DB::table('system_notification_user')->insert([ + 'notification_id' => $notification->id, + 'user_id' => $user->id, + 'read_at' => null, + 'deleted_at' => null, + ]); + + return $notification; +} + +// ── Access control ──────────────────────────────────────────────────────────── + +test('guest cannot view notification center', function () { + $this->get('/notification-center')->assertRedirect('/login'); +}); + +test('user without permission is forbidden from notification center', function () { + $user = User::factory()->create(); + + $this->actingAs($user)->get('/notification-center')->assertForbidden(); +}); + +test('user with view permission can access notification center', function () { + $user = makeNotificationViewer(); + + $this->actingAs($user)->get('/notification-center')->assertOk(); +}); + +// ── Store (broadcast) ───────────────────────────────────────────────────────── + +test('viewer without manage permission cannot broadcast notifications', function () { + $viewer = makeNotificationViewer(); + Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']); + + $this->actingAs($viewer) + ->postJson('/notification-center', [ + 'title' => 'Test', + 'message' => 'Msg', + 'recipient' => 'User', + 'type' => 'info', + ]) + ->assertForbidden(); +}); + +test('admin can broadcast a notification', function () { + $admin = makeNotificationAdmin(); + Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']); + + $this->actingAs($admin) + ->postJson('/notification-center', [ + 'title' => 'Important Update', + 'message' => 'Please read.', + 'recipient' => 'User', + 'type' => 'info', + ]) + ->assertOk() + ->assertJsonPath('success', true); + + expect(Notification::where('title', 'Important Update')->count())->toBe(1); +}); + +test('store rejects unknown notification type', function () { + $admin = makeNotificationAdmin(); + Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']); + + $this->actingAs($admin) + ->postJson('/notification-center', [ + 'title' => 'Bad Type', + 'message' => 'Msg', + 'recipient' => 'User', + 'type' => 'xss-alert', + ]) + ->assertStatus(422); +}); + +test('store requires title and message', function () { + $admin = makeNotificationAdmin(); + + $this->actingAs($admin) + ->postJson('/notification-center', ['type' => 'info']) + ->assertStatus(422); +}); + +// ── Mark as read ────────────────────────────────────────────────────────────── + +test('user can mark a notification as read', function () { + $user = makeNotificationViewer(); + $notification = makeNotificationForUser($user); + + $this->actingAs($user) + ->patchJson(route('notification-center.read', $notification->id)) + ->assertOk() + ->assertJsonPath('success', true); +}); + +test('guest cannot mark notifications as read', function () { + $notification = Notification::create([ + 'title' => 'X', 'message' => 'Y', 'recipient' => 'all', 'type' => 'info', + ]); + + // JSON requests return 401 Unauthorized (not a redirect) when unauthenticated + $this->patchJson(route('notification-center.read', $notification->id)) + ->assertUnauthorized(); +}); + +// ── Mark all as read ────────────────────────────────────────────────────────── + +test('user can mark all notifications as read', function () { + $user = makeNotificationViewer(); + + $this->actingAs($user) + ->patchJson(route('notification-center.read-all')) + ->assertOk() + ->assertJsonPath('success', true); +}); + +// ── Personal delete ─────────────────────────────────────────────────────────── + +test('user can delete (hide) a notification from their view', function () { + $user = makeNotificationViewer(); + $notification = makeNotificationForUser($user); + + $this->actingAs($user) + ->deleteJson(route('notification-center.destroy', $notification->id)) + ->assertOk() + ->assertJsonPath('success', true); +}); + +// ── Feature flag ────────────────────────────────────────────────────────────── + +test('feature disabled blocks regular viewers but allows manage-global-settings users', function () { + app(SystemConfigService::class)->update(['feature_notification_center' => false]); + + $viewer = makeNotificationViewer(); + + $this->actingAs($viewer) + ->get('/notification-center') + ->assertForbidden(); +}); + +test('feature flag disabled still allows users with manage global settings', function () { + app(SystemConfigService::class)->update(['feature_notification_center' => false]); + + $admin = makeNotificationViewer(); + $perm = Permission::firstOrCreate(['name' => 'manage global settings', 'guard_name' => 'web']); + $admin->givePermissionTo($perm); + + $this->actingAs($admin) + ->get('/notification-center') + ->assertOk(); +}); + +// ── Recent notifications API ────────────────────────────────────────────────── + +test('recent notifications endpoint returns json with unread count', function () { + $user = makeNotificationViewer(); + + $this->actingAs($user) + ->getJson('/notification-center/api/recent') + ->assertOk() + ->assertJsonStructure(['success', 'unread_count', 'notifications', 'has_more']); +}); + +test('recent notifications content is escaped', function () { + $user = makeNotificationViewer(); + + $notification = Notification::create([ + 'title' => '', + 'message' => 'bold', + 'recipient' => 'all', + 'type' => 'info', + 'created_by' => $user->id, + ]); + + \DB::table('system_notification_user')->insert([ + 'notification_id' => $notification->id, + 'user_id' => $user->id, + 'read_at' => null, + 'deleted_at' => null, + ]); + + $response = $this->actingAs($user) + ->getJson('/notification-center/api/recent') + ->json(); + + $titles = collect($response['notifications'])->pluck('title')->all(); + // Title must be HTML-entity-escaped, not raw script tag + foreach ($titles as $t) { + expect($t)->not->toContain('