في الجزء الأول شرحنا لماذا يتحدث تطبيقك على جهازك مع بدائل محلية للخدمات الخارجية، وفي الجزء الثاني قارنا الأدوات واخترنا منها Mailpit لالتقاط البريد، وmkcert للشهادات، وCaddy أمام التطبيق، وCoreDNS للأسماء. وفي هذا الجزء نجمعها في ملف Docker Compose واحد حول تطبيق صغير فيه ميزة حقيقية واحدة، هي «نسيت كلمة المرور»، ثم نكتب اختباراً آلياً يفتح المتصفح ويطلب رابط الاستعادة ويقرأ البريد من Mailpit ويفتح الرابط، ثم نكسر البيئة عمداً لنرى الأخطاء التي سوف تراها أنت يوماً ما.
والمواضيع في السلسلة:
- بيئة التطوير المحلية: الفكرة، وما الذي تحاكيه، ولماذا.
- أدوات بيئة التطوير المحلية: المقارنة وجدول القرار.
- التطبيق العملي بملف Docker Compose واحد (الموضوع الحالي).
وسوف نناقش في هذا المقال ما يلي:
- شكل البيئة: شبكة داخلية لا تصل إلى الإنترنت، وCoreDNS يجيب عن كل اسم تحت
lab.test، وCaddy بشهادة من mkcert. - التطبيق وحارس البريد الذي يمنعه من العمل بسيرفر SMTP حقيقي في وضع التطوير.
- جعل متصفح واحد داخل Container يثق بجهة الإصدار المحلية، دون أن نلمس مخزن الثقة على جهازك.
- اختبار آلي ب Playwright وAPI Mailpit يتأكد أن بريد الاستعادة يصل وأن رابطه يعمل.
- ميزة Chaos في Mailpit: كيف يتصرف التطبيق عندما يرفض سيرفر البريد الرسالة.
- أربعة كسور: سيرفر SMTP حقيقي، والمحتوى المختلط Mixed Content، وشهادة لاسم آخر، وCookie آمن على HTTP.
mkcert -install على جهازك، ولا نعدل ملف hosts، ولا نغير أي إعداد في النظام، فجهة الإصدار تنشأ في مجلد التجربة، والثقة بها تكون داخل Container الاختبار وحده، والشبكة كلها داخلية فلا يخرج منها بريد حقيقي حتى لو أخطأنا في الإعداد. وإذا قررت أن تثق بجهة mkcert على جهازك فعلاً فاقرأ قبلها قسم المخاطر في الجزء الأول.شكل البيئة
البيئة خمسة Containers على شبكة Docker واحدة من نوع internal، وهذا النوع لا يملك طريقاً إلى خارج جهازك، وقد شرحنا أنواع شبكات Docker في مقال شبكات Docker. والصورة التالية تبين البيئة:

وترتيب الملفات كما يلي:
lab/
├── compose.yaml
├── Corefile # CoreDNS: *.lab.test → Caddy
├── Caddyfile # sites, mkcert certificate
├── static/hello.js # served over plain HTTP, for the mixed content test
├── app/ # Node.js app: server.mjs, package.json, Dockerfile
└── tests/ # Playwright tests: reset.test.mjs, open.mjs, Dockerfileالخطوة 1: جهة إصدار وشهادة من mkcert
نصدر الشهادة في Container مؤقت كما فعلنا في قسم mkcert في الجزء الثاني، ونحفظ جهة الإصدار في المجلد ca والشهادة في المجلد certs، وكلاهما خارج مجلد المشروع حتى لا يرفع المفتاح إلى Git بالخطأ:
mkdir -p ~/lab-ldev/ca ~/lab-ldev/certs
docker run --rm --name lab-ldev-mkcert -e CAROOT=/ca \
-v ~/lab-ldev/ca:/ca -v ~/lab-ldev/certs:/certs -w /certs alpine:3.22 sh -c '
wget -q -O /usr/local/bin/mkcert https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-amd64
chmod +x /usr/local/bin/mkcert
mkcert -cert-file lab.pem -key-file lab-key.pem "lab.test" "*.lab.test"'
export LAB_CA=~/lab-ldev/ca LAB_CERTS=~/lab-ldev/certsوالنتيجة أربعة ملفات: rootCA.pem وrootCA-key.pem لجهة الإصدار، وlab.pem وlab-key.pem للشهادة التي تغطي lab.test وكل اسم بمستوى واحد تحته، وتنتهي في 12 يناير 2029. ولاحظ في ملف Compose بعد قليل أن Container الاختبار يأخذ rootCA.pem وحده، أما المفتاح rootCA-key.pem فلا يدخل أي Container.
الخطوة 2: الأسماء مع CoreDNS
ملف Corefile يجيب عن كل اسم تحت lab.test بعنوان Caddy الثابت، ويرسل أي اسم آخر إلى DNS الداخلي في Docker الذي يعرف أسماء ال Containers:
# Every name under lab.test (and lab.test itself) points to Caddy; everything else goes to Docker's own DNS
lab.test:53 {
template IN A {
answer "{{ .Name }} 60 IN A 10.231.77.10"
}
template IN AAAA {
rcode NOERROR
}
log
}
.:53 {
forward . 127.0.0.11
log
}في الإعداد أعلاه لاحظ التالي:
- إضافة template تبني الإجابة من اسم السؤال نفسه، فـ
app.lab.testوanything.lab.testيحصلان على العنوان نفسه، وهذا ما لا يستطيعه ملف hosts كما رأينا في الجزء الثاني. - السؤال عن AAAA يحصل على إجابة فارغة بالرمز NOERROR، والسبب أن المكتبات تسأل عن IPv4 وIPv6 معاً، والإجابة الفارغة السريعة أفضل من سؤال ينتظر حتى تنتهي مهلته.
- العنوان
127.0.0.11هو DNS المدمج في Docker داخل كل Container، فيبقى اسم مثلmailpitيعمل، أما الأسماء الخارجية فلن يجد لها إجابة لأن الشبكة داخلية.
الخطوة 3: Caddy بشهادة mkcert
{
# the certificates come from mkcert; Caddy must not try ACME or touch any trust store
auto_https disable_certs
skip_install_trust
}
(lab_tls) {
tls /certs/lab.pem /certs/lab-key.pem
}
app.lab.test {
import lab_tls
reverse_proxy app:3000
}
mail.lab.test {
import lab_tls
reverse_proxy mailpit:8025
}
# a name the wildcard does not cover: *.lab.test matches one label only
api.v2.lab.test {
import lab_tls
respond "v2 API"
}
# plain HTTP on purpose, for the Secure cookie and mixed content tests
http://plain.lab.test {
reverse_proxy app:3000
}
http://static.lab.test {
root * /srv/static
file_server
}في الإعداد أعلاه لاحظ التالي:
- الخيار
auto_https disable_certsيوقف إصدار الشهادات التلقائي ويبقي التحويل من HTTP إلى HTTPS، والخيارskip_install_trustيمنع Caddy من محاولة إضافة جهته الداخلية إلى أي مخزن ثقة كما شرحنا في الجزء الثاني. - الموقع
api.v2.lab.testوالموقعان على HTTP موجودة لتجارب الكسر فقط، وتطبيقك الحقيقي لا يحتاجها. - تفاصيل Caddy نفسه وطريقة تثبيته على سيرفر في دليل Caddy.
الخطوة 4: التطبيق وحارس البريد
التطبيق في الملف app/server.mjs بلغة Node.js مع مكتبة nodemailer 10.1.0، ويقرأ إعداد SMTP من متغيرات البيئة كما يفعل في الإنتاج، والجزء المهم منه هو السطور الأولى:
const {
APP_ENV = 'development',
APP_URL = 'https://app.lab.test',
SMTP_HOST = 'mailpit',
SMTP_PORT = '1025',
MAIL_FROM = '[email protected]',
DEV_SMTP_ALLOW = 'mailpit,localhost,127.0.0.1',
} = process.env;
// Guard 1: outside production the app refuses to start with an SMTP server that is not a mail catcher
if (APP_ENV !== 'production' && !DEV_SMTP_ALLOW.split(',').includes(SMTP_HOST)) {
console.error(`refusing to start: APP_ENV=${APP_ENV} but SMTP_HOST=${SMTP_HOST} is not in DEV_SMTP_ALLOW (${DEV_SMTP_ALLOW})`);
process.exit(1);
}
const mailer = nodemailer.createTransport({ host: SMTP_HOST, port: Number(SMTP_PORT), secure: false, connectionTimeout: 5000 });وبقية الملف أربعة مسارات: الصفحة الرئيسية التي تضع Cookie الجلسة بالخواص Secure; HttpOnly; SameSite=Lax، وصفحة /forgot التي تستقبل البريد وتنشئ رمزاً عشوائياً وترسل رسالة فيها الرابط https://app.lab.test/reset/<token>، وصفحة الرابط نفسها، وصفحة /mixed لتجربة المحتوى المختلط، وتجد الملف كاملاً في مجلد التجربة. ولاحظ أن الحارس طبقة أولى فقط، فالطبقة الثانية هي الشبكة الداخلية، وسوف نكسر الأولى ونرى الثانية تعمل.
الخطوة 5: ملف Compose
# Local dev stack: app + Mailpit + Caddy (mkcert certificates) + CoreDNS for *.lab.test
# Everything sits on one internal network: no container here can reach the internet.
name: lab-ldev
networks:
lab:
name: lab-ldev-net
internal: true
ipam:
config:
- subnet: 10.231.77.0/24
services:
dns:
image: coredns/coredns:1.14.7
container_name: lab-ldev-dns
command: ["-conf", "/etc/coredns/Corefile"]
volumes:
- ./Corefile:/etc/coredns/Corefile:ro
networks:
lab: { ipv4_address: 10.231.77.53 }
mailpit:
image: axllent/mailpit:v1.31.5
container_name: lab-ldev-mailpit
environment:
MP_ENABLE_CHAOS: "true" # lets the tests make the SMTP server fail on purpose
MP_SMTP_RELAY_ALL: "false" # never release mail to the real world
networks: [lab]
app:
build: ./app
image: lab-ldev-app:1
container_name: lab-ldev-app
environment:
APP_ENV: development
APP_URL: https://app.lab.test
SMTP_HOST: ${SMTP_HOST:-mailpit}
SMTP_PORT: ${SMTP_PORT:-1025}
DEV_SMTP_ALLOW: ${DEV_SMTP_ALLOW:-mailpit,localhost,127.0.0.1}
dns: [10.231.77.53]
networks: [lab]
caddy:
image: caddy:2.11.7-alpine
container_name: lab-ldev-caddy
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./static:/srv/static:ro
- ${LAB_CERTS:?set LAB_CERTS}:/certs:ro
networks:
lab: { ipv4_address: 10.231.77.10 }
tests:
build: ./tests
image: lab-ldev-pw:1
container_name: lab-ldev-tests
profiles: [tests]
environment:
NODE_EXTRA_CA_CERTS: /ca/rootCA.pem # Node trusts the lab CA inside this container only
volumes:
- ${LAB_CA:?set LAB_CA}/rootCA.pem:/ca/rootCA.pem:ro
- ./tests:/tests/src:ro
dns: [10.231.77.53]
networks: [lab]
command: ["sleep", "infinity"]في الإعداد أعلاه لاحظ التالي:
internal: trueهو الحماية الأهم في الملف، فلا يوجد أي Container هنا يستطيع أن يصل إلى سيرفر بريد حقيقي، ولهذا أيضاً لا توجد منافذ منشورة على جهازك، فكل الطلبات تأتي من Container الاختبار داخل الشبكة نفسها.- التطبيق وContainer الاختبار يستخدمان CoreDNS عبر
dns، أما Caddy فلا يحتاجه لأنه يتصل بالخدمات بأسمائها في Docker. - ال Container الخاص بالاختبار مبني على
mcr.microsoft.com/playwright:v1.64.0-nobleومعه Playwright 1.64.0 وحزمةlibnss3-toolsالتي فيها الأداةcertutil، وهو في ملف تعريف Profile اسمهtestsفلا يعمل إلا عندما تطلبه. - القيمة
MP_SMTP_RELAY_ALLبـ false هي القيمة الافتراضية أصلاً، وكتبناها لتبقى واضحة لمن يقرأ الملف بعدك.
الآن نشغل البيئة:
docker compose -p lab-ldev --profile tests up -d --build
docker ps --filter name=lab-ldev --format '{{.Names}} {{.Status}}'والمخرج سوف يكون كما يلي:
lab-ldev-caddy Up 44 seconds
lab-ldev-tests Up 21 seconds
lab-ldev-app Up 38 seconds
lab-ldev-dns Up 39 seconds
lab-ldev-mailpit Up 42 seconds (healthy)ونتأكد من الأسماء من داخل Container الاختبار:
docker exec lab-ldev-tests sh -c '
dig +short app.lab.test; dig +short whatever.lab.test; dig +short api.v2.lab.test
getent hosts mail.lab.test
dig example.com | grep status'والمخرج سوف يكون كما يلي:
10.231.77.10
10.231.77.10
10.231.77.10
10.231.77.10 mail.lab.test
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 30630ولاحظ السطر الأخير، فالاسم الخارجي example.com لا يجد إجابة، وهذه أول علامة أن الشبكة لا تصل إلى الإنترنت.
الخطوة 6: متصفح واحد يثق بجهة الإصدار
قبل أن نضيف جهة الإصدار نفتح التطبيق بالأمر curl ثم بالمتصفح، والملف tests/open.mjs يفتح أي رابط في Chromium دون الخيار ignoreHTTPSErrors ويطبع النتيجة وال Cookies ورسائل ال Console:
docker exec lab-ldev-tests curl -sS https://app.lab.test/ -o /dev/null
docker exec -w /tests lab-ldev-tests node src/open.mjs https://app.lab.test/والمخرج سوف يكون كما يلي:
curl: (60) SSL certificate problem: unable to get local issuer certificate
https://app.lab.test/ → page.goto: net::ERR_CERT_AUTHORITY_INVALID at https://app.lab.test/وهذا هو السلوك الصحيح، فلا أحد يثق بجهة mkcert بعد. ومتصفح Chromium على Linux يقرأ الجهات الإضافية من قاعدة NSS في المجلد ~/.pki/nssdb للمستخدم، وهنا المستخدم هو root داخل ال Container، فنضيف الجهة إلى هذه القاعدة وحدها:
docker exec lab-ldev-tests sh -c '
mkdir -p $HOME/.pki/nssdb
certutil -d sql:$HOME/.pki/nssdb -N --empty-password
certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n lab-ldev-ca -i /ca/rootCA.pem
certutil -d sql:$HOME/.pki/nssdb -L'والمخرج سوف يكون كما يلي:
Certificate Nickname Trust Attributes
SSL,S/MIME,JAR/XPI
lab-ldev-ca C,,أما Node.js فيثق بالجهة عبر المتغير NODE_EXTRA_CA_CERTS الذي وضعناه في ملف Compose، وcurl عبر الخيار --cacert، وكل هذا داخل Container يختفي مع docker compose down، والشهادة التي يقدمها Caddy الآن كما يلي:
subject=O = mkcert development certificate, OU = root@74748a9e7516
issuer=O = mkcert development CA, OU = root@74748a9e7516, CN = mkcert root@74748a9e7516
notBefore=Oct 12 10:12:05 2026 GMT
notAfter=Jan 12 10:12:05 2029 GMT
X509v3 Subject Alternative Name:
DNS:lab.test, DNS:*.lab.testالخطوة 7: اختبار آلي لبريد استعادة كلمة المرور
الآن الجزء الذي بنينا البيئة من أجله، فالاختبار التالي يحذف كل الرسائل من Mailpit، ثم يفتح صفحة /forgot ويكتب بريداً فريداً ويضغط الزر، ثم يسأل ال API في Mailpit حتى تصل رسالة لهذا العنوان، ثم يستخرج الرابط من نص الرسالة ويفتحه ويتأكد من الصفحة. والصورة التالية تبين الخطوات:

والملف tests/reset.test.mjs كما يلي:
// End-to-end test: "forgot password" must deliver a working reset link, and we read the email from Mailpit's API.
// node --test /tests/src/reset.test.mjs
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
const APP = 'https://app.lab.test';
const MAILPIT = 'https://mail.lab.test/api/v1';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let browser, page;
// Poll Mailpit until a message for this address arrives (email is asynchronous; never use a fixed sleep)
async function waitForMail(to, timeoutMs = 5000) {
const q = encodeURIComponent(`to:"${to}"`);
for (const end = Date.now() + timeoutMs; Date.now() < end; await sleep(200)) {
const r = await (await fetch(`${MAILPIT}/search?query=${q}`)).json();
if (r.messages_count > 0) return (await fetch(`${MAILPIT}/message/${r.messages[0].ID}`)).json();
}
throw new Error(`no email for ${to} within ${timeoutMs} ms`);
}
before(async () => {
await fetch(`${MAILPIT}/messages`, { method: 'DELETE' }); // start from an empty inbox
browser = await chromium.launch();
page = await browser.newPage(); // no ignoreHTTPSErrors: the lab CA must really be trusted
});
after(async () => { await browser?.close(); });
test('password reset email arrives and its link works', async () => {
const to = `sara+${Date.now()}@example.com`;
await page.goto(`${APP}/forgot`);
await page.fill('input[name=email]', to);
await page.click('button');
await page.waitForSelector('#status');
const msg = await waitForMail(to);
assert.equal(msg.Subject, 'Reset your password');
assert.equal(msg.From.Address, '[email protected]');
const link = msg.Text.match(/https:\/\/app\.lab\.test\/reset\/[0-9a-f]{32}/)?.[0];
assert.ok(link, 'reset link in the email body');
await page.goto(link);
assert.equal(await page.textContent('h1'), 'Choose a new password');
assert.equal(await page.textContent('#who'), to);
});docker exec -w /tests lab-ldev-tests node --test src/reset.test.mjsوالمخرج سوف يكون كما يلي:
✔ password reset email arrives and its link works (30886.73043ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0في الاختبار أعلاه لاحظ التالي:
- العنوان
sara+<timestamp>@example.comفريد في كل تشغيل، فلا يجد الاختبار رسالة من تشغيل سابق، وexample.comنطاق محجوز للأمثلة فلا يملك صندوقاً حقيقياً حتى لو خرجت الرسالة. ولاحظ أن Mailpit يحول الجزء بعد علامة+إلى وسم Tag على الرسالة، فظهر الرقم كوسم في الواجهة. - الدالة
waitForMailتسأل كل 200 ملي ثانية حتى 5 ثوان، وهذا هو الفرق بين اختبار ثابت واختبار متقلب Flaky، فالانتظار الثابت إما طويل يبطئ كل التشغيلات أو قصير يفشل على جهاز مشغول، وقد شرحنا هذه المشكلة في قسم الاختبارات المتقلبة. - الاختبار يفتح الرابط بالمتصفح نفسه، فهو يختبر أن الرابط في البريد يشير إلى النطاق الصحيح وأن الرمز يعمل، وهذا خطأ شائع عندما يبنى الرابط من
localhostبدل عنوان التطبيق العام. - الزمن الكلي قرابة 31 ثانية لأن الجهاز كانت تعمل عليه أعمال أخرى ثقيلة أثناء التجربة، وأغلبه تشغيل Chromium، وليس انتظار البريد.
والصورة التالية من واجهة Mailpit داخل المتصفح نفسه في Container الاختبار:

الخطوة 8: عندما يرفض سيرفر البريد الرسالة
الاختبار السابق يختبر الطريق السعيد Happy Path فقط، أما في الإنتاج فسيرفر البريد قد يرد برفض مؤقت مثل 451، وميزة Chaos في Mailpit تجعله يفعل ذلك عند الطلب. فقد فعلناها في ملف Compose بالمتغير MP_ENABLE_CHAOS، والآن نطلب منه أن يرفض كل مستلم بالرمز 451، ثم نطلب رابط استعادة، ثم نعيد الإعداد:
docker exec lab-ldev-tests sh -c 'C="--cacert /ca/rootCA.pem -s"
curl $C -X PUT -H "Content-Type: application/json" \
-d "{\"Recipient\":{\"ErrorCode\":451,\"Probability\":100}}" https://mail.lab.test/api/v1/chaos; echo
curl $C -o /dev/null -w "HTTP %{http_code}\n" -d [email protected] https://app.lab.test/forgot
curl $C -X PUT -H "Content-Type: application/json" -d "{}" https://mail.lab.test/api/v1/chaos; echo
curl $C -o /dev/null -w "HTTP %{http_code}\n" -d [email protected] https://app.lab.test/forgot'
docker logs lab-ldev-app 2>&1 | tail -2والمخرج سوف يكون كما يلي:
{"Sender":{"ErrorCode":451,"Probability":0},"Recipient":{"ErrorCode":451,"Probability":100},"Authentication":{"ErrorCode":535,"Probability":0}}
HTTP 503
{"Sender":{"ErrorCode":451,"Probability":0},"Recipient":{"ErrorCode":451,"Probability":0},"Authentication":{"ErrorCode":535,"Probability":0}}
HTTP 202
reset mail for [email protected] failed: Can't send mail - all recipients were rejected: 451 Chaos recipient error
reset mail for [email protected] accepted by mailpit: 250 2.0.0 Ok: queued as 1nPp2ImToreousghgjnJjPتطبيقنا الصغير يرد ب 503 ويطلب من المستخدم المحاولة لاحقاً، وهذا مقبول في تجربة، أما في تطبيق حقيقي فالرد 451 مؤقت ويعني «حاول لاحقاً»، والتصرف الصحيح أن تضع الرسالة في طابور Queue يعيد المحاولة ويرد على المستخدم بنجاح، كما شرحنا في قسم الطابور وإعادة المحاولة. وهنا تظهر فائدة Chaos، فهو يختبر هذا الكود دون أن تنتظر يوماً يفشل فيه مزود البريد فعلاً.
نكسرها: التطبيق يشير إلى سيرفر SMTP حقيقي
لنفرض أن أحد الفريق نسخ ملف .env من بيئة staging إلى جهازه، فأصبح التطبيق في وضع التطوير يشير إلى سيرفر بريد خارجي، وسوف نستخدم الاسم smtp.example.com لأنه في نطاق محجوز للأمثلة:
SMTP_HOST=smtp.example.com SMTP_PORT=587 docker compose -p lab-ldev up -d app
docker logs lab-ldev-appوالمخرج سوف يكون كما يلي:
refusing to start: APP_ENV=development but SMTP_HOST=smtp.example.com is not in DEV_SMTP_ALLOW (mailpit,localhost,127.0.0.1)الطبقة الأولى عملت، والتطبيق رفض أن يبدأ. والآن نفترض أسوأ حالة، وهي أن أحداً أضاف السيرفر الخارجي إلى قائمة السماح ليتجاوز الحارس، مرة بالاسم ومرة بعنوان IP من نطاق التوثيق 192.0.2.0/24:
SMTP_HOST=smtp.example.com SMTP_PORT=587 DEV_SMTP_ALLOW=smtp.example.com docker compose -p lab-ldev up -d app
docker exec lab-ldev-tests curl -s --cacert /ca/rootCA.pem -o /dev/null -w "HTTP %{http_code}\n" -d [email protected] https://app.lab.test/forgot
docker logs lab-ldev-app 2>&1 | tail -1
SMTP_HOST=192.0.2.25 SMTP_PORT=587 DEV_SMTP_ALLOW=192.0.2.25 docker compose -p lab-ldev up -d app
docker exec lab-ldev-tests curl -s --cacert /ca/rootCA.pem -o /dev/null -w "HTTP %{http_code}\n" -d [email protected] https://app.lab.test/forgot
docker logs lab-ldev-app 2>&1 | tail -1والمخرج سوف يكون كما يلي:
HTTP 503
reset mail for [email protected] failed: DNS lookup for smtp.example.com timed out
HTTP 503
reset mail for [email protected] failed: connect ENETUNREACH 192.0.2.25:587 - Local (0.0.0.0:0)في المخرج أعلاه لاحظ التالي:
- بالاسم فشل التطبيق قبل أن يحاول الاتصال، لأن الشبكة الداخلية لا تستطيع أن تسأل DNS خارجياً، وبالعنوان المباشر رد النظام بالخطأ
ENETUNREACHلأن الشبكة لا تملك أي طريق إلى خارج الجهاز. - الطبقتان مستقلتان، فالحارس في الكود يحمي حتى لو شغل أحد التطبيق خارج Docker، والشبكة الداخلية تحمي حتى لو كان في الكود خطأ أو مكتبة ترسل بريداً من مكان لا تعرفه.
ونعيد التطبيق إلى إعداده الصحيح:
docker compose -p lab-ldev up -d appنكسرها: المحتوى المختلط Mixed Content
الصفحة https://app.lab.test/mixed تحمل ملف JavaScript من http://static.lab.test، وهذا يحدث كثيراً عندما يبني التطبيق روابط ملفاته من إعداد قديم أو من ترويسة لم يضبطها ال Reverse Proxy، كما شرحنا في مقال تطبيقك خلف Reverse Proxy، وعلى HTTP المحلي لا تراه أبداً:
docker exec -w /tests lab-ldev-tests node src/open.mjs https://app.lab.test/mixed
docker exec lab-ldev-tests curl -s http://static.lab.test/hello.jsوالمخرج سوف يكون كما يلي:
https://app.lab.test/mixed → 200 #out="script not loaded"
cookies: []
error: Mixed Content: The page at 'https://app.lab.test/mixed' was loaded over HTTPS, but requested an insecure script 'http://static.lab.test/hello.js'. This request has been blocked; the content must be served over HTTPS.
document.getElementById('out').textContent = 'script loaded over http';الملف موجود ويعمل بالأمر curl، لكن المتصفح رفض تحميله داخل صفحة HTTPS كما يشرح توثيق MDN، وبالتالي بقيت الصفحة على النص الأول، وهذا بالضبط ما يراه المستخدم في الإنتاج: صفحة تعمل نصف عمل دون أي خطأ في سجلات السيرفر.
نكسرها: شهادة لاسم آخر
أضاف الفريق إصداراً ثانياً من ال API على api.v2.lab.test، واستخدم الشهادة نفسها:
docker exec lab-ldev-tests curl -sS --cacert /ca/rootCA.pem https://api.v2.lab.test/
docker exec -w /tests lab-ldev-tests node src/open.mjs https://api.v2.lab.test/والمخرج سوف يكون كما يلي:
curl: (60) SSL: no alternative certificate subject name matches target host name 'api.v2.lab.test'
https://api.v2.lab.test/ → page.goto: net::ERR_CERT_COMMON_NAME_INVALID at https://api.v2.lab.test/الجهة موثوقة هنا، والمشكلة في الاسم، فالشهادة الشاملة *.lab.test تغطي مستوى واحداً، وmkcert نفسه ذكرنا بذلك عند إصدارها. والحل إما شهادة جديدة فيها *.v2.lab.test، أو اسم بمستوى واحد مثل api-v2.lab.test، ونفس القاعدة تنطبق على شهادات Let's Encrypt الشاملة في الإنتاج.
نكسرها: Cookie آمن على HTTP
التطبيق يضع Cookie الجلسة sid بالخاصية Secure، والآن نفتح الصفحة نفسها مرة عبر HTTPS ومرة عبر الاسم plain.lab.test الذي يخدم التطبيق نفسه على HTTP:
docker exec -w /tests lab-ldev-tests node src/open.mjs https://app.lab.test/ http://plain.lab.test/والمخرج سوف يكون كما يلي:
https://app.lab.test/ → 200
cookies: ["sid secure=true"]
http://plain.lab.test/ → 200
cookies: []على HTTP رفض المتصفح ال Cookie بصمت، فلا يوجد خطأ في الصفحة ولا في السيرفر، والذي يراه المستخدم أنه يسجل الدخول ثم يعود إلى صفحة الدخول. وهذه المشكلة لا تظهر على localhost لأن المتصفح يستثنيه كما رأينا في تجربة الجزء الأول، وتظهر أول مرة عندما يفتح أحد التطبيق باسم آخر، وتفاصيل خواص ال Cookies في مقال الجلسات وJWT. ولهذا السبب بالذات تستحق بيئة التطوير HTTPS على اسم حقيقي، فهي تكشف هذا الخطأ على جهازك قبل أن يكتشفه المستخدم.
إزالة التجربة
نحذف ما أنشأناه بالأسماء فقط، ثم نحذف جهة الإصدار نفسها لأن مفتاحها لا يلزم بعد التجربة:
docker compose -p lab-ldev --profile tests down -v
docker image rm lab-ldev-app:1 lab-ldev-pw:1
rm -rf ~/lab-ldev/ca ~/lab-ldev/certsكيف تستخدم هذا الإعداد كل يوم؟
- ضع ملف Compose والملفات الثلاثة
CorefileوCaddyfileوtests/في مستودع المشروع، وأبق جهة الإصدار خارجه، ولكل مطور جهته. - إذا أردت أن تفتح التطبيق من متصفح جهازك نفسه فأمامك طريقان: أن تنشر منفذ Caddy على
127.0.0.1وتستخدم أسماء*.localhost، أو أن توجه نطاق.testإلى CoreDNS وتثق بجهة mkcert على جهازك، والثاني قرار يحتاج تفكيراً كما شرحنا في قسم المخاطر. - شغل الاختبار نفسه في خط CI بالملف نفسه، فهو لا يحتاج إنترنت ولا حسابات، وانظر مقال البيئات وGitOps لما بعد جهازك.
- عندما تحتاج قاعدة بيانات أو تخزين S3 أضفها كخدمة أخرى على الشبكة نفسها، وللاختبارات المعزولة لكل تشغيل استخدم Testcontainers كما في الجزء الثاني.
أخطاء شائعة
ignoreHTTPSErrors: trueفي الاختبارات: يجعل الاختبار ينجح مع أي شهادة، فلا يكشف شهادة لاسم خاطئ ولا محتوى مختلطاً، والصحيح أن تثق بالجهة داخل Container الاختبار.- انتظار ثابت بعد إرسال البريد: اسأل API Mailpit حتى تصل الرسالة بدل
sleep(3000). - البحث عن الرسالة بالعنوان فقط: رسائل التشغيلات السابقة تبقى، فاحذفها في بداية الاختبار أو استخدم عنواناً فريداً، أو الاثنين معاً كما فعلنا.
- شبكة Compose عادية بدل
internal: عندها يصل أي خطأ في إعداد SMTP إلى الإنترنت مباشرة. - رفع
rootCA-key.pemإلى المستودع: كل من يقرأ المستودع يستطيع إصدار شهادة يقبلها جهاز كل من وثق بهذه الجهة. - إنشاء رابط البريد من ترويسة الطلب: ابن الرابط من إعداد ثابت مثل
APP_URL، والسبب أن ترويسة Host يتحكم فيها من يرسل الطلب.
الخلاصة
- ملف Compose واحد بشبكة
internalيجمع التطبيق وMailpit وCaddy وCoreDNS، ولا يخرج منه أي بريد حتى لو أخطأ الإعداد. - سيرفر CoreDNS بإضافة template يجيب عن كل اسم تحت
lab.test، وCaddy يقدم شهادة mkcert، والثقة بالجهة داخل Container الاختبار وحده عبر NSS وNODE_EXTRA_CA_CERTS. - الاختبار الآلي يطلب رابط الاستعادة ويقرأ الرسالة من API Mailpit ويفتح الرابط، ونجح بعد أن وثقنا بالجهة، وقبلها فشل المتصفح بالخطأ
ERR_CERT_AUTHORITY_INVALID. - ميزة Chaos جعلت Mailpit يرد ب 451، فظهر كيف يتصرف التطبيق عندما يرفض سيرفر البريد الرسالة.
- عندما وجهنا التطبيق إلى سيرفر حقيقي رفض الحارس أن يبدأ، وعندما تجاوزناه فشل الاتصال بالخطأين
DNS lookup timed outوENETUNREACH. - المحتوى المختلط، والشهادة الشاملة على اسم بمستويين، وال Cookie الآمن على HTTP، ثلاثة أخطاء ظهرت هنا على جهازك بدل أن تظهر في الإنتاج.
دليلبيئة التطوير المحلية: كيف تحاكي البريد وDNS وHTTPS على جهازكلماذا تستبدل البريد وDNS وHTTPS ببدائل محلية أثناء التطوير، وكيف يعامل المتصفح localhost، وأي النطاقات تتجنب.
دليلأدوات بيئة التطوير المحلية: ماذا تستخدم لكل خدمة؟مقارنة بالقياس بين أدوات التقاط البريد والشهادات المحلية والأسماء ومحاكاة ال API والسحابة، وأيها تختار لمشروعك.
دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.
دليلمن الفرع إلى الإنتاج: البيئات وGitOps للفرقكيف يختبر فريقك كل ميزة وحدها ويعرضها على العميل دون أن تصل ميزة غير جاهزة إلى الإنتاج.سجل التحديثات
- أكتوبر 2026: كتابة المقال، والتجربة على Docker Engine 29.8.0 وDocker Compose v5.5.1 مع CoreDNS 1.14.7 وCaddy 2.11.7 وMailpit v1.31.5 وNode.js 24.21.0 وnodemailer 10.1.0 وmkcert v1.4.4، والاختبار ب Playwright 1.64.0 (Chromium 156.0.8078.4) وcertutil من NSS 3.98 داخل Ubuntu 24.04، والمعلومات من توثيق Mailpit وCaddy وCoreDNS وMDN كما قرأناه في 12 أكتوبر 2026.