All three Phase 3 tickets verified on beta
Forty-one automated checks passed (12 for SP-614, 12 for SP-615, 14 for SP-616, plus 3 deployment-currency checks). Three minor findings were filed (SP-626, SP-627, SP-628). Two items were not exercisable on beta and are listed under Not verified on beta. Three observations were recorded. None of the findings block the phase.
What was verified
One row per Phase 3 ticket. Each result links to the evidence section below.
| Ticket | Scope | Result | Evidence |
|---|---|---|---|
| SP-614 | Per-tenant email identity and optional custom SMTP (PR #300) | PASS | Section 4.1 |
| SP-615 | Per-tenant legal identity and generic legal pages (PR #301) | PASS | Section 4.2 |
| SP-616 | Tenant-aware payments via payments.providers (PR #302) |
PASS | Section 4.3 |
Deployment currency
Before any behavioural check, the beta environment was confirmed to be running the head of main. Three deployment-currency checks passed.
Beta runs merge commit c4318ca7
| Check | Expected | Observed | Result |
|---|---|---|---|
| 1. main HEAD and Phase 3 stack | Head of main is the SP-616 merge; the stacked chain is present. |
c4318ca7 on HEAD. Stack 3f375eff → 45fb61db → c4318ca7. |
PASS |
| 2. Backend deployment | Cloud Run beta serves that merge commit. | Deploy Beta run 32760215113 success. Ready revision lms-backend-beta-00253-gd6, image tag c4318ca7602247f68d4efd083698b023614821db. |
PASS |
| 3. Frontend deployment | DigitalOcean beta frontend serves the same commit, with Phase 3 markers in the bundle. | Deployment ACTIVE 5/5, cause “commit c4318ca pushed”. Bundle contains payment_providers, legalEntityName, has_password, privacy_url and the “Payments are not available” copy. |
PASS |
Evidence by ticket
Each card records the checks executed, the observed responses, and the supporting screenshots.
SP-614 — Per-tenant email identity and optional custom SMTP
All checks against HistoryClass (tenant 7c1e9f42-3b6a-4d58-9e10-2f4c8a7b5d31, host historyclass.beta.skillpixel.vn) with the beta admin account (skillpixeldeveloper@gmail.com, TENANT_ADMIN on HistoryClass) unless stated. Layer 1 default is the tenant name on the platform address.
| Check | Expected | Observed | Result |
|---|---|---|---|
1. Baseline GET /admin/tenant-settings/email |
200: from_name null, reply_to null, effective_from_name “HistoryClass”, effective_from_email info@skillpixel.vn, effective_reply_to null, custom SMTP disabled, has_password false. |
As expected. Layer 1 default = tenant name on the platform address. | PASS |
2. PUT identity |
200 for from_name “HistoryClass Team”, reply_to hello@historyclass.vn. Effective from-name “HistoryClass Team”, from-email remains info@skillpixel.vn, effective Reply-To hello@historyclass.vn. |
As expected. Platform address retained, as documented. | PASS |
3. GET again |
Values persisted. | Values persisted. | PASS |
4. PUT custom SMTP |
200 for enabled SMTP: host smtp.historyclass.example, port 587, username mailer@historyclass.example, password supplied, from_email noreply@historyclass.example, STARTTLS on. has_password true. Password never echoed. |
200. has_password true. Password has 0 occurrences in the response. |
PASS |
| 5. Database audit of the stored password | Fernet ciphertext in tenants.email_settings.custom_smtp.password_encrypted. Plaintext absent from the row. |
Value starts with “gAAAAAB”, length 120. The plaintext does not appear anywhere in the row. | PASS |
6. Partial PUT without a password field |
200. Port 465, STARTTLS off. has_password stays true. |
200. has_password stayed true. Port and STARTTLS updated. The partial-PUT wipe fix holds. |
PASS |
| 7. Validation | Custom SMTP enabled without a host → 422 “Custom SMTP requires a host and a from address”. reply_to “not-an-email” → 422. |
Both requests returned 422 with the documented messages. | PASS |
| 8. Authorisation | PUT by a signed-in non-member (curoa99@gmail.com) → 403 “Tenant membership is required”. Anonymous GET → 403. |
As expected. | PASS |
9. POST /admin/tenant-settings/email/test |
200 {"sent": false, "detail": "Email delivery is disabled on this environment"} on the shared relay and with custom SMTP enabled. |
As expected. The environment guard answers cleanly instead of attempting a send. | PASS |
| 10. Default tenant isolation | Default tenant (beta.skillpixel.vn) GET: from_name null, effective_from_name “SkillPixel”, effective_from_email info@skillpixel.vn, has_password false. |
Unchanged after all HistoryClass writes. | PASS |
11. Admin UI /admin/email |
Saved identity and SMTP block, masked password with the keep-password hint, the from preview line, the test-email panel, and a sidebar “Email” entry. | See screenshot 01. | PASS |
| 12. Logs | Sends are no-ops on beta. The notification path runs for a non-default tenant without error. | Two “Email delivery skipped outside production” entries at 19:15:21 UTC, from the enrollment notifications of the free checkout in SP-616. | PASS |
/admin/email after saving. Sender identity “HistoryClass Team” / Reply-To hello@historyclass.vn. Preview line “Email được gửi từ: HistoryClass Team <noreply@historyclass.example>”. Custom SMTP block enabled with host smtp.historyclass.example, port 465, masked password and hint “Để trống để giữ mật khẩu đã lưu”, STARTTLS off, and the “Gửi email thử” panel. Sidebar shows the new “Email” entry.SP-615 — Per-tenant legal identity and generic legal pages
| Check | Expected | Observed | Result |
|---|---|---|---|
| 1. Migration backfill | Default tenant legal entity and contact filled. HistoryClass legal empty before the test. Default canonical_url is https://beta.skillpixel.vn (no www). |
Default legal.entity_name = “Công Ty Cổ Phần Doanh Nghiệp Xã Hội SkillPixel”, links.phone “+84-934-099-409”, links.address “Số 15A Trần Khánh Dư, Phường Tân Tiến, Thành phố Nha Trang, Tỉnh Khánh Hòa, Việt Nam”. HistoryClass legal {} before the test. Canonical URL as expected. |
PASS |
2. PUT /admin/tenant-settings on HistoryClass |
200 for legal {entity_name "Công Ty TNHH HistoryClass", tax_code "0101234567"} and links {contact_email hello@historyclass.vn, phone "+84-900-123-456", address "12 Nguyễn Trãi, Hà Nội"}. |
200. | PASS |
3. GET /tenant/bootstrap |
Legal and links reflected. No SkillPixel copy in the payload. | Legal and links reflected. The only “skillpixel” substring is inside the beta hostname (canonical_url / logo path), not copy. |
PASS |
4. privacy_url scheme validation |
A non-https, non-relative value is rejected with 422 “must be a relative path (/x) or an https URL”. | 422 with that message for the javascript: probe value recorded on the ticket. |
PASS |
| 5. External and relative legal URLs | 200 for privacy_url https://historyclass.example/privacy and terms_url /terms. Bootstrap reflects both. |
200. Bootstrap reflects both. | PASS |
6. HistoryClass /privacy-policy, signed out |
Title “Chính sách Bảo mật | HistoryClass”. Body contains “Công Ty TNHH HistoryClass” and hotline “+84-900-123-456”. Zero case-insensitive “SkillPixel” matches. Old hotline 0905121773 absent. | As expected. Screenshot 02. | PASS |
7. HistoryClass /terms, signed out |
Title “Điều khoản Sử dụng | HistoryClass”. Entity name and hotline present. Any “skillpixel” match is only the interpolated beta site URL. | The single “skillpixel” match is “https://historyclass.beta.skillpixel.vn” (the beta hostname — expected; a production tenant domain would not contain it). Screenshot 03. | PASS |
| 8. HistoryClass footer | Quick links only “Điều khoản sử dụng” → /terms and “Chính sách bảo mật” → https://historyclass.example/privacy (target _blank). No About or FAQ. Contact block shows tenant phone, email and address. |
No “Về chúng tôi” (/about) and no “Câu hỏi thường gặp” (/faq). Contact “+84-900-123-456 / hello@historyclass.vn / 12 Nguyễn Trãi, Hà Nội”. Copyright “© 2025 HistoryClass.” (SP-623 known). Screenshot 04. |
PASS |
9. HistoryClass /register consent links |
“Điều khoản sử dụng” → /terms (internal). “Chính sách Bảo mật” → https://historyclass.example/privacy (external, new tab). |
Signup consent follows the external URL. Screenshot 05. | PASS |
| 10. Tenant admin Branding page | New “Pháp lý” card with entity name, tax code and both external-URL fields populated. | Tên pháp nhân “Công Ty TNHH HistoryClass”, Mã số thuế 0101234567, Link Chính sách bảo mật riêng https://historyclass.example/privacy, Link Điều khoản sử dụng riêng /terms. Contact block shows hello@historyclass.vn / +84-900-123-456 / 12 Nguyễn Trãi, Hà Nội. Screenshot 06. |
PASS |
| 11. Default tenant legal pages and footer | Title “Chính sách Bảo mật | SkillPixel”. Full registered entity and new hotline. Old hotline 0905121773 absent. Footer keeps About, FAQ, terms and privacy. | Body shows “Công Ty Cổ Phần Doanh Nghiệp Xã Hội SkillPixel” and hotline “+84-934-099-409”. Footer keeps “Về chúng tôi” /about, “Câu hỏi thường gặp” /faq, /terms, /privacy-policy. Screenshots 11 and 12. |
PASS |
12. Direct URL /faq and /about on HistoryClass |
Generic FAQ with no SkillPixel literal. About should not render SkillPixel copy on a non-default tenant. | /faq renders the generic FAQ page with no SkillPixel literal in the body. /about renders SkillPixel's about page. Finding SP-626. Screenshot 13. |
PASS |
/privacy-policy, signed out. Full page. Title “Chính sách Bảo mật | HistoryClass”.
/terms, signed out. Full page. Title “Điều khoản Sử dụng | HistoryClass”.
/register. Consent links “Điều khoản sử dụng” → /terms and “Chính sách Bảo mật” → https://historyclass.example/privacy (target _blank).
/admin/branding. Full page with the new “Pháp lý” card: Tên pháp nhân “Công Ty TNHH HistoryClass”, Mã số thuế 0101234567, Link Chính sách bảo mật riêng https://historyclass.example/privacy, Link Điều khoản sử dụng riêng /terms. Contact block shows hello@historyclass.vn / +84-900-123-456 / 12 Nguyễn Trãi, Hà Nội.
beta.skillpixel.vn/privacy-policy. Full page. Title “Chính sách Bảo mật | SkillPixel”.
/about opened by direct URL. Renders SkillPixel's about hero “Ươm mầm cho Tài năng Việt qua Giáo dục AI…” under the HistoryClass header. Finding SP-626.SP-616 — Tenant-aware payments via payments.providers
Buyer is the beta admin account (a STUDENT member of HistoryClass). To exercise the paid path, the HistoryClass course “Lịch sử THPT - Ôn thi tốt nghiệp” (slug lich-su-thpt-on-thi-tot-nghiep) was temporarily priced at 150.000 ₫ (all HistoryClass courses are free on beta) and reverted afterwards.
| Check | Expected | Observed | Result |
|---|---|---|---|
| 1. Migration backfill | Exactly one payments.providers row, TENANT scope, default tenant, value ["ALEPAY","SEPAY"]. HistoryClass has no row. |
One row for tenant 0a46592b-9318-4e72-ae93-9635d7364d82 (skillpixel), updated_by sp-616-backfill. HistoryClass has no row. |
PASS |
| 2. Bootstrap and providers, both tenants | Default: bootstrap payment_providers ["ALEPAY","SEPAY"]; GET /payments/providers returns two providers. HistoryClass: bootstrap []; providers []; GET /platform/config/effective effective_value null (new tenants start off). |
As expected. | PASS |
| 3. Catalog entry | GET /platform/config/catalog exposes payments.providers: value_type string_list, default [], editable_by platform_admin, is_feature false. |
As expected. | PASS |
| 4. GLOBAL-scope write rejected | PUT /platform/config/entry at GLOBAL scope → 400 “Feature entitlements are per-tenant. Write this key at TENANT scope and supply a tenant_id.” |
400 with that message. | PASS |
| 5. Unknown provider rejected | PUT with value ["PAYPAL"] → 400 “Config key payments.providers requires a list of payment providers (ALEPAY, NGANLUONG, SEPAY).”. Effective value unchanged afterwards. |
400. Effective value unchanged. | PASS |
| 6. Empty list: initiate | POST /payments/initiate (course lich-su-thpt-on-thi-tot-nghiep, provider ALEPAY) → 403 “Payments are not available for this site”. |
403 with that message. | PASS |
| 7. Empty list: paid cart checkout | POST /cart/checkout with the paid course, provider SEPAY → 403 “Payments are not available for this site”. |
403 with that message. | PASS |
| 8. Empty list: free cart | Cart with the free course lich-su-viet-nam-the-ky-19-dau-20, POST /cart/checkout → 200. Zero-cost carts never reach the gate. Enrollment created. |
200 {payment_required false, transaction_id 5JKUQJ64CBIIOIDUHJRH6, redirect to /payment/receipt}. Enrollment was created. |
PASS |
9. TENANT list ["SEPAY"] |
PUT /platform/config/entry TENANT HistoryClass ["SEPAY"] → 200. Effective, bootstrap and providers all ["SEPAY"]. |
200 (updated_by skillpixeldeveloper@gmail.com). Effective ["SEPAY"], matched_scope TENANT. Bootstrap ["SEPAY"]. GET /payments/providers → ["SEPAY"] only. |
PASS |
10. Initiate with ["SEPAY"] |
ALEPAY initiate → 403 (provider outside the list). SEPAY initiate → 200 with payment required. Status PROCESSING then cancel → CANCELLED. | ALEPAY → 403. SEPAY → 200 transaction JZEFR3NV7ORMP4T0CU6R2, payment_required true, redirect https://pay-sandbox.sepay.vn/v1/checkout/init. GET status → PROCESSING, SEPAY, 150000. POST cancel → CANCELLED. |
PASS |
11. Cart checkout with ["SEPAY"] |
POST /cart/checkout SEPAY → 200. Cancel → CANCELLED. |
200 transaction QQQ354A45297VS3W6WAOX. Cancelled → CANCELLED. |
PASS |
| 12. Callbacks are not gated on the HistoryClass host | Bogus SePay and AlePay webhook bodies → 400 “Invalid webhook payload” (not a 404 feature gate). SePay return → 422 (request validation, not a gate). NganLuong bogus body is a known 500. | POST /payments/webhook/sepay and /webhook/alepay → 400 “Invalid webhook payload”. GET /payments/return/sepay → 422. POST /webhook/nganluong → 500 (pre-existing, finding SP-627). |
PASS |
| 13. Default tenant isolation | Default tenant GET /payments/providers still ["ALEPAY","SEPAY"] after all HistoryClass writes. |
Unchanged. | PASS |
| 14. Cart and platform-admin UI | Empty list: payments-unavailable notice, no checkout button. ["SEPAY"]: SePay card hidden by the PostHog kill switch; maintenance notice shows. ["ALEPAY"]: checkout button active. Platform-admin Features tab shows the tenant override. |
Screenshots 07, 08, 09 and 10. Finding SP-628 on the maintenance copy. | PASS |
/cart with payments.providers empty. A 150.000 ₫ course. Amber notice “Thanh toán hiện chưa khả dụng trên trang này.” and no checkout button.
payments.providers = ["SEPAY"]. The SePay PostHog kill switch is off on beta, so the SePay card is hidden and the pre-existing maintenance notice “Cổng thanh toán đang bảo trì … liên hệ SkillPixel qua Facebook” shows. Finding SP-628.
payments.providers = ["ALEPAY"]. The “Đăng ký ngay” checkout button is active (AlePay is the only route).
["SEPAY"] and the catalog description “Providers a tenant may start checkouts with (ALEPAY, SEPAY, NGANLUONG). Empty disables new checkouts; provider callbacks keep working.”Not verified on beta
Two items could not be exercised on this environment. They are covered by unit tests. They are not counted as failures.
Real email delivery
Real email delivery, rendered tenant-branded templates and the “[<tenant name>] …” reminder subjects were not exercised.
EMAIL_DELIVERY_ENABLED is unset on beta, so every send is a no-op (“Email delivery skipped outside production”).
Covered by unit tests (test_email_identity.py, test_email_service.py, test_email_reminder_service.py).
To exercise on beta: set EMAIL_DELIVERY_ENABLED=true in Infisical beta and roll a new Cloud Run revision, then press “Gửi email thử” on /admin/email.
Note this also turns on real invitation and reminder mail on beta.
End-to-end provider webhook
A provider webhook for an enabled tenant's transaction completing end to end was not exercised. It needs a real SePay sandbox payment.
Covered by test_tenant_payment_gating.py. On beta only the ungated routing of the callback routes was confirmed (SP-616 check 12).
Database and logs audit
Reported separately from the 41 checks. The purpose is to confirm isolation and that the only errors since the deploy were the deliberate NganLuong probe.
Default-tenant rows unchanged; two ERROR lines from the NganLuong probe
- Alembic head
a1f7c2d94b83. Columnstenants.email_settingsandtenants.legalare present. - Cloud Run ERROR entries since the deploy (2026-08-24T18:00Z): 2, both at 19:09:46 UTC and both produced by the deliberate NganLuong webhook probe (SP-627). No other errors.
- Default tenant rows for email settings, legal and
payments.providerswere unchanged by the test. - HistoryClass transactions after the test: 139 COMPLETED 0 ₫ (free checkout), 140 and 141 CANCELLED 150.000 ₫ (SePay initiations cancelled by the test).
Findings
Three bugs were filed. None block the phase. Three observations follow.
SP-626 — /about and /faq still served on non-default tenants by direct URL
/about and /faq are still served on non-default tenants by direct URL.
/about renders SkillPixel's about page (title “Về SkillPixel | HistoryClass”).
SP-615 only hid the footer links.
Expected: not-found or redirect on non-default tenants. Visible in screenshot 13.
SP-627 — NganLuong webhook returns 500 on a malformed body
Pre-existing since 2026-01-19. Surfaced by SP-616 check 12.
POST /payments/webhook/nganluong with a malformed body returns 500 (unhandled pydantic ValidationError at payment.py line 374) while the AlePay and SePay webhooks return 400.
Not tenant-related.
SP-628 — SePay maintenance notice names SkillPixel on a tenant cart
The SePay kill-switch maintenance notice on the cart says “liên hệ SkillPixel qua Facebook” on a HistoryClass page (frontend key payment.maintenance).
Visible in screenshot 08.
O1 — custom_smtp: null means “no change”
PUT /admin/tenant-settings/email with custom_smtp: null leaves the stored custom SMTP untouched (null means “no change”).
To disable, the client sends the object with enabled false — the admin UI does this.
Documented behaviour, no action.
O2 — Shared-relay From address stays the platform address
On the shared relay the From address stays info@skillpixel.vn for every tenant (known limit recorded on the ticket and the roadmap).
Only the from-name and Reply-To are per tenant. The admin page states this in its help text.
O3 — Footer copyright year remains “© 2025”
SP-623 (footer “© 2025”) is still visible on every footer capture. Unchanged, already ticketed.
Test residue and cleanup
All residue on beta is intentional and recorded here so that a later reader does not mistake it for real data.
Staging identity kept; payments off; course price reverted
- HistoryClass email settings.
from_name“HistoryClass Team” andreply_tohello@historyclass.vnKEPT (sensible staging identity). Custom SMTP disabled and cleared (host/username/from_email null,password_encryptednull,has_passwordfalse). - HistoryClass legal.
entity_name“Công Ty TNHH HistoryClass” andtax_code0101234567 KEPT (fictional staging identity, matching the runbook example).privacy_urlandterms_urlcleared (null). Contact linkshello@historyclass.vn/ +84-900-123-456 / 12 Nguyễn Trãi, Hà Nội KEPT. payments.providers. HistoryClass now has a TENANT row with value[](payments off, explicit), description “HistoryClass is not selling on beta (reset after Phase 3 verification 2026-08-25)”.- Course price and cart. Course
lich-su-thpt-on-thi-tot-nghiepcost reverted to 0. The admin's cart was emptied. - Left in place. Transactions 139/140/141 (see audit) and the enrollment of
skillpixeldeveloper@gmail.cominlich-su-viet-nam-the-ky-19-dau-20created by the free checkout. - Bootstrap cache. The public site picks up tenant-setting changes within about five minutes.
Method
Checks were API-driven: curl against the beta Cloud Run API with Firebase ID tokens minted for the beta admin account.
The database audit was performed with psql on the beta Supabase database.
The log audit was performed with gcloud.
Screenshots come from clean headless Chromium (Playwright) with the Firebase session injected.
Verified by Claude (Fable 5) on 2026-08-25 ICT (2026-08-24 19:05–19:30 UTC).