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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,68 @@ jobs:
SECRET_KEY: test-key
DEBUG: 'False'
ALLOWED_HOSTS: localhost,127.0.0.1,testserver,blog.iohub.link
# Run the suite against the same engine as production (service container above)
TEST_DB_ENGINE: postgres
POSTGRES_HOST: localhost
run: |
python -m pytest -v --cov=techblog_cms --cov-report=xml

# Boot the real compose stack (db + redis + django) and hit it end-to-end,
# so entrypoint, migrations, cache auth, and the container healthcheck are
# proven to work together on every PR. nginx is excluded: its config
# requires TLS certificates that only exist on the production host.
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Generate a self-consistent .env
run: |
cat > .env <<'ENV'
SECRET_KEY=ci-smoke-secret-key
DEBUG=False
ALLOWED_HOSTS=localhost,127.0.0.1,django
POSTGRES_DB=techblogdb
POSTGRES_USER=techblog
POSTGRES_PASSWORD=techblogpass
DATABASE_URL=postgres://techblog:techblogpass@db:5432/techblogdb
REDIS_PASSWORD=ci-smoke-redis-pass
REDIS_URL=redis://redis:6379/1
ENV

- name: Start db, redis, and django
run: docker compose up -d --build db redis django

- name: Wait for the django healthcheck
run: |
cid=$(docker compose ps -q django)
for i in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' "$cid" 2>/dev/null || echo starting)
[ "$status" = healthy ] && exit 0
sleep 5
done
echo "django never became healthy (last status: $status)"
docker compose logs django
exit 1

- name: Probe the running application
run: |
docker compose exec -T django python - <<'PY'
import requests
secure = {'X-Forwarded-Proto': 'https'}
for path, headers in [('/health/', {}), ('/ready/', {}), ('/', secure), ('/articles/', secure)]:
r = requests.get('http://localhost:8000' + path, headers=headers,
allow_redirects=False, timeout=15)
print(path, r.status_code)
assert r.status_code == 200, f'{path} returned {r.status_code}'
PY

- name: Show logs on failure
if: failure()
run: docker compose logs django db redis

build:
needs: test
needs: [test, smoke]
runs-on: ubuntu-latest

steps:
Expand Down
5 changes: 3 additions & 2 deletions Dockerfile.django
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ COPY . .

# Create a non-root user to run the application.
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
RUN chown -R appuser:appgroup /app
RUN mkdir -p /app/logs && chown -R appuser:appgroup /app/logs
# static/media/logs must exist owned by appuser so fresh named volumes
# mounted there inherit that ownership on first use.
RUN mkdir -p /app/static /app/media /app/logs && chown -R appuser:appgroup /app

# Entrypoint to run migrations and collectstatic
RUN chmod +x /app/docker/entrypoint.sh
Expand Down
90 changes: 0 additions & 90 deletions assitant.agent.prompt.md

This file was deleted.

6 changes: 4 additions & 2 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,18 @@ if [ ! -f /app/logs/error.log ]; then
fi

# ログディレクトリの権限設定
# chown は root 実行時のみ成功する。非 root(compose の user: appuser)では
# ボリューム所有権はイメージ側の設定(Dockerfile)で継承されるため、失敗しても致命的でない。
echo "Setting up log directory..."
mkdir -p /app/logs
chown -R appuser:appgroup /app/logs
chown -R appuser:appgroup /app/logs || true
chmod 755 /app/logs || true
chmod 664 /app/logs/access.log || true

# 静的ファイルディレクトリの作成と権限設定(collectstatic 前に実施)
echo "Preparing static directory..."
mkdir -p /app/static
chown -R appuser:appgroup /app/static
chown -R appuser:appgroup /app/static || true
chmod -R 755 /app/static || true

# -------------------------------------------
Expand Down
10 changes: 0 additions & 10 deletions techblog_cms/context_processors.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
import os
import sys

from django.core.cache import cache

from .models import Category, Tag


SIDEBAR_CACHE_TTL = 300

def testing_mode(request):
"""Add IS_TESTING variable to template context"""
IS_TESTING = os.environ.get('TESTING') == 'True' or 'PYTEST_CURRENT_TEST' in os.environ or any(
x.endswith('pytest') for x in sys.modules.keys()
)
return {'IS_TESTING': IS_TESTING}


def sidebar(request):
categories = cache.get('sidebar:categories')
Expand Down
41 changes: 23 additions & 18 deletions techblog_cms/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'techblog_cms.context_processors.testing_mode',
'techblog_cms.context_processors.sidebar',
],
},
Expand All @@ -66,26 +65,42 @@

# Database
# Detect testing mode either via explicit env var or when running under pytest
IS_TESTING = os.environ.get('TESTING') == 'True' or 'PYTEST_CURRENT_TEST' in os.environ or any(
IS_TESTING = os.environ.get('TESTING') == 'True' or 'PYTEST_CURRENT_TEST' in os.environ or 'test' in sys.argv or any(
x.endswith('pytest') for x in sys.modules.keys()
)
logger.debug("IS_TESTING: %s", IS_TESTING)

if IS_TESTING:
# Testing uses SQLite and in-memory cache for simplicity and isolation.
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
# Tests default to SQLite for speed; set TEST_DB_ENGINE=postgres (as CI
# does) to run them against the same engine as production.
if os.environ.get('TEST_DB_ENGINE') == 'postgres':
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('POSTGRES_DB', 'techblogdb'),
'USER': os.environ.get('POSTGRES_USER', 'techblog'),
'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'techblogpass'),
'HOST': os.environ.get('POSTGRES_HOST', 'localhost'),
'PORT': os.environ.get('POSTGRES_PORT', '5432'),
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
}
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'techblog-test-cache',
}
}
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
DEBUG = True
APPEND_SLASH = False
else:
Expand Down Expand Up @@ -321,13 +336,3 @@
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@techblog.com')
ADMINS = [('Admin', config('ADMIN_EMAIL', default='admin@techblog.com'))]

# Testing configuration
if 'test' in sys.argv:
DATABASES['default'] = {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
8 changes: 5 additions & 3 deletions techblog_cms/templates/article_editor.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ <h2 class="mb-5 text-xl font-semibold">{% if article %}記事編集{% else %}記
{% endif %}

<form method="post" id="articleForm" enctype="multipart/form-data" action="{% if article %}{% url 'article_edit' article.slug %}{% else %}{% url 'article_new' %}{% endif %}" class="flex flex-col h-full">
{% if not IS_TESTING %}
{% csrf_token %}
{% endif %}

<div class="form-group mb-4">
<label for="title" class="font-bold">タイトル</label>
Expand Down Expand Up @@ -236,9 +234,13 @@ <h3>ショートカットキー</h3>

async function serverRenderMarkdown(markdown) {
try {
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
const resp = await fetch('/api/preview_markdown/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken ? csrfToken.value : ''
},
body: 'text=' + encodeURIComponent(markdown || '')
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
Expand Down
2 changes: 0 additions & 2 deletions techblog_cms/templates/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@ <h1>ログイン</h1>
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="post">
{% if not IS_TESTING %}
{% csrf_token %}
{% endif %}
<div class="form-group">
<label for="username">ユーザー名</label>
<input id="username" name="username" type="text" class="form-control" required />
Expand Down
55 changes: 55 additions & 0 deletions techblog_cms/tests/test_csrf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from django.contrib.auth.models import User
from django.test import Client, TestCase
from django.urls import reverse


class CsrfProtectionTests(TestCase):
"""The custom login and preview endpoints must reject requests without a CSRF token."""

def setUp(self):
self.user = User.objects.create_user(username="editor", password="pass1234")
self.csrf_client = Client(enforce_csrf_checks=True)

def test_login_post_without_token_is_rejected(self):
response = self.csrf_client.post(
reverse("login"),
{"username": "editor", "password": "pass1234"},
)
self.assertEqual(response.status_code, 403)

def test_login_with_token_from_form_succeeds(self):
login_page = self.csrf_client.get(reverse("login"))
self.assertContains(login_page, "csrfmiddlewaretoken")
token = self.csrf_client.cookies["csrftoken"].value

response = self.csrf_client.post(
reverse("login"),
{
"username": "editor",
"password": "pass1234",
"csrfmiddlewaretoken": token,
},
)
self.assertRedirects(response, reverse("dashboard"))

def test_preview_post_without_token_is_rejected(self):
self.csrf_client.force_login(self.user)
response = self.csrf_client.post(reverse("preview_markdown"), {"text": "# hi"})
self.assertEqual(response.status_code, 403)

def test_preview_post_with_token_succeeds(self):
self.csrf_client.force_login(self.user)
editor_page = self.csrf_client.get(reverse("article_new"))
self.assertEqual(editor_page.status_code, 200)
self.assertContains(editor_page, "csrfmiddlewaretoken")
# Regression guard: the preview fetch must keep sending the header.
self.assertContains(editor_page, "X-CSRFToken")
token = self.csrf_client.cookies["csrftoken"].value

response = self.csrf_client.post(
reverse("preview_markdown"),
{"text": "# hi"},
HTTP_X_CSRFTOKEN=token,
)
self.assertEqual(response.status_code, 200)
self.assertIn("html", response.json())
Loading
Loading