From the original night's low-priority backlog item ("user on hold
status") — the only lever admin had for cutting a customer's access was
Revoke, which is permanent: the subscription's remaining days are just
gone, and restoring access means manually granting a brand-new one and
eyeballing how many days to give back. No way to say "block this for a
few days, then give the exact remaining time back."
db.py: new held_at column on subscriptions (same ALTER-TABLE migration
pattern as every other column added this week). hold_subscription()
sets it, guarded to only fire on a subscription that's currently active,
not already held, not expired — returns False instead of silently
no-opping so the caller can tell holding didn't happen. resume_subscription()
shifts expires_at forward by exactly how long it was held (now - held_at)
and clears held_at, so a subscription paused for 3 days comes back with
3 days added, not 3 days lost.
The part that actually mattered for correctness: list_active_subscriptions()
now also requires held_at IS NULL. This function is what xray_manager's
periodic sync (every 90s) uses to decide which clients belong in Xray's
config — without this exclusion, holding a subscription would look like
it worked for about 90 seconds and then the next sync would silently
re-add the client, since the row still has active=1 and a future
expires_at. Found this by actually tracing sync_from_db()/sync_all()
before writing the hold logic, not after debugging a live failure.
api.py: POST .../hold and .../resume routes, mirroring the existing
revoke route (fetch the sub, touch the node's xray client immediately
rather than waiting for the next periodic sync, same as revoke already
does). _days_left() now takes the whole subscription row instead of just
expires_at, so it can use held_at as the reference point instead of "now"
for a held subscription — otherwise the admin UI would show the days
counter silently ticking down while the customer isn't even able to use
the service.
admin.html: Пауза/Возобновить buttons next to Отозвать in both the main
Подписки table and the per-user card, a "на паузе" badge, and a doc-block
explaining the hold-vs-revoke distinction. Also fixed a latent race while
touching this code: the old inline revoke handler in the user card fired
openUserCard() immediately alongside revokeSub() without waiting for it,
so the card could refresh before the revoke's own API call had finished;
switched to .then() so hold/resume/revoke all correctly wait for the
action before refreshing the card.
Verification: db.py has no fastapi/aiogram dependency so this was fully
testable locally, unlike most of tonight's api.py/bot.py-touching work.
16 checks against a real isolated sqlite db: hold/resume round-trip,
the exclude-from-active-list behavior the xray sync depends on, the
exact hours-shift math (simulated a 5h hold by rewriting held_at
directly, verified the resumed expires_at landed within 6 minutes of
the expected shift), and edge cases — double-hold, double-resume,
holding an expired or already-revoked subscription, nonexistent uuid.
AST-extracted the updated _days_left() out of api.py (still can't
import the module directly) and ran it against hand-built held/active
subscription dicts. Added the same hold/resume sequence to the existing
CI "TOTP/backup/reorder" step and ran that step's exact full script
locally end to end before committing — all six of its sections pass
together, not just the new one in isolation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
290 lines
12 KiB
YAML
290 lines
12 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
pull_request:
|
|
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-python@v5
|
|
with:
|
|
python-version: "3.12"
|
|
|
|
- name: Install dependencies
|
|
run: pip install -r requirements.txt
|
|
|
|
- name: Compile check all Python files
|
|
run: python -m compileall -q .
|
|
|
|
- name: Shell syntax check
|
|
run: |
|
|
bash -n install.sh
|
|
bash -n mbs
|
|
|
|
- name: Smoke test install-script rendering
|
|
env:
|
|
BOT_TOKEN: "x"
|
|
BOT_USERNAME: "x"
|
|
ADMIN_IDS: "1"
|
|
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
|
|
PANEL_DOMAIN: "panel.test"
|
|
SUB_DOMAIN: "sub.test"
|
|
SITE_DOMAIN: "test"
|
|
XRAY_PUBLIC_KEY: "x"
|
|
XRAY_SHORT_ID_TCP: "x"
|
|
XRAY_SHORT_ID_GRPC: "x"
|
|
XRAY_SHORT_ID_XHTTP: "x"
|
|
run: |
|
|
python - << 'PYEOF'
|
|
import json
|
|
import nodeprov
|
|
|
|
transports = nodeprov.build_transports("fi2.example.com", 443, "www.microsoft.com", "PUBKEY", include_ws=True)
|
|
node = {
|
|
"provision_token": "TESTTOKEN",
|
|
"address": "fi2.example.com",
|
|
"sni": "www.microsoft.com",
|
|
"private_key": "PRIVKEY",
|
|
"transports_json": json.dumps(transports),
|
|
"hysteria_enabled": 1,
|
|
"hysteria_port": 443,
|
|
"hysteria_password": "hypass",
|
|
"hysteria_obfs_password": "obfspass",
|
|
}
|
|
script = nodeprov.render_install_script(node)
|
|
assert "PRIVKEY" in script
|
|
assert len(script) > 500
|
|
print("node install script rendered OK,", len(script), "bytes")
|
|
PYEOF
|
|
|
|
- name: Smoke test app wiring + payments + HWID logic
|
|
env:
|
|
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
|
BOT_USERNAME: "x"
|
|
ADMIN_IDS: "1"
|
|
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
|
|
PANEL_DOMAIN: "panel.test"
|
|
SUB_DOMAIN: "sub.test"
|
|
SITE_DOMAIN: "test"
|
|
XRAY_PUBLIC_KEY: "x"
|
|
XRAY_SHORT_ID_TCP: "x"
|
|
XRAY_SHORT_ID_GRPC: "x"
|
|
XRAY_SHORT_ID_XHTTP: "x"
|
|
PAYMENTS_ENABLED: "true"
|
|
YOOKASSA_ENABLED: "true"
|
|
YOOKASSA_SHOP_ID: "123"
|
|
YOOKASSA_SECRET_KEY: "xxx"
|
|
PLATEGA_ENABLED: "true"
|
|
PLATEGA_MERCHANT_ID: "abc"
|
|
PLATEGA_SECRET: "yyy"
|
|
HWID_LIMIT_ENABLED: "true"
|
|
run: |
|
|
python - << 'PYEOF'
|
|
import hashlib
|
|
import hmac
|
|
|
|
import api
|
|
import bot
|
|
import payments
|
|
import db
|
|
|
|
assert set(payments.available_providers()) == {"yookassa", "platega"}
|
|
|
|
good_sig = hmac.new(b"yyy", b'{"a":1}', hashlib.sha256).hexdigest()
|
|
assert payments.verify_platega_signature(b'{"a":1}', good_sig)
|
|
assert not payments.verify_platega_signature(b'{"a":1}', "wrong")
|
|
|
|
db.init_db()
|
|
db.create_payment("pid1", 1, "de1", "1m", "yookassa", 399)
|
|
assert db.mark_payment_paid("pid1")["status"] == "paid"
|
|
assert db.mark_payment_paid("pid1") is None
|
|
|
|
db.get_or_create_user(1, "tester")
|
|
db.add_device(1, "hwid-aaaaaaaaaa", "android", "Pixel", "ua")
|
|
assert db.count_devices(1) == 1
|
|
assert db.get_device(1, "hwid-aaaaaaaaaa") is not None
|
|
|
|
print("app wiring + payments + HWID logic OK")
|
|
|
|
import legal
|
|
import settings
|
|
|
|
assert settings.get_brand_name() == "MBS Panel"
|
|
legal.update_env_var("BRAND_NAME", "CI Test Brand")
|
|
assert settings.get_brand_name() == "CI Test Brand"
|
|
|
|
index_html = legal.render_site_page("index.html")
|
|
assert "CI Test Brand" in index_html
|
|
assert "MBS Panel" not in index_html
|
|
assert "example.com" not in index_html
|
|
assert "YourBot_robot" not in index_html
|
|
assert "{{" not in index_html and "}}" not in index_html
|
|
|
|
cabinet_html = legal.render_site_page("cabinet.html")
|
|
assert "CI Test Brand" in cabinet_html
|
|
assert "{{" not in cabinet_html and "}}" not in cabinet_html
|
|
|
|
fake_request = type("FakeRequest", (), {"headers": {}})()
|
|
root_resp = api.root(fake_request)
|
|
assert "CI Test Brand" in root_resp
|
|
|
|
plans_resp = api.public_plans()
|
|
assert plans_resp["plans"][0]["code"] == "7d"
|
|
|
|
branding_resp = api.public_branding()
|
|
assert branding_resp["brand_name"] == "CI Test Brand"
|
|
|
|
print("branding: site templates + public routes render live, no restart OK")
|
|
PYEOF
|
|
|
|
- name: Smoke test TOTP, backup/restore, node reorder, multi-admin, rate-limit
|
|
env:
|
|
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
|
BOT_USERNAME: "x"
|
|
ADMIN_IDS: "1"
|
|
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
|
|
PANEL_DOMAIN: "panel.test"
|
|
SUB_DOMAIN: "sub.test"
|
|
SITE_DOMAIN: "test"
|
|
XRAY_PUBLIC_KEY: "x"
|
|
XRAY_SHORT_ID_TCP: "x"
|
|
XRAY_SHORT_ID_GRPC: "x"
|
|
XRAY_SHORT_ID_XHTTP: "x"
|
|
run: |
|
|
python - << 'PYEOF'
|
|
import base64
|
|
import db
|
|
import totp
|
|
|
|
raw_key = b"12345678901234567890"
|
|
secret = base64.b32encode(raw_key).decode("ascii").rstrip("=")
|
|
expected = ["755224","287082","359152","969429","338314","254676","287922","162583","399871","520489"]
|
|
for counter, exp in enumerate(expected):
|
|
assert totp._hotp(secret, counter) == exp, f"RFC 4226 vector failed at counter={counter}"
|
|
print("TOTP: all 10 RFC 4226 test vectors pass")
|
|
|
|
db.init_db()
|
|
|
|
db.create_node("n1", "Node One", "managed", "1.1.1.1", 443, "pub1", "sid1", "sni1", "xtls-rprx-vision")
|
|
db.create_node("n2", "Node Two", "managed", "2.2.2.2", 443, "pub2", "sid2", "sni2", "xtls-rprx-vision")
|
|
order = [n["code"] for n in db.list_nodes()]
|
|
assert order == ["de1", "n1", "n2"], order
|
|
db.reorder_nodes(["n2", "de1", "n1"])
|
|
assert [n["code"] for n in db.list_nodes()] == ["n2", "de1", "n1"]
|
|
try:
|
|
db.reorder_nodes(["n2", "de1"])
|
|
assert False, "should reject incomplete reorder list"
|
|
except ValueError:
|
|
pass
|
|
print("node reorder OK")
|
|
|
|
import backup
|
|
data = backup.create_backup()
|
|
db.create_node("n3", "Node Three", "managed", "3.3.3.3", 443, "pub3", "sid3", "sni3", "xtls-rprx-vision")
|
|
assert len(db.list_nodes()) == 4
|
|
backup.restore_backup(data)
|
|
assert len(db.list_nodes()) == 3, "restore should have reverted the extra node"
|
|
print("backup/restore round-trip OK")
|
|
|
|
admin = db.verify_admin_login("admin", "ci-test-password-not-real")
|
|
assert admin is not None
|
|
second = db.create_admin("second", "another-strong-password")
|
|
assert len(db.list_admins()) == 2
|
|
try:
|
|
db.delete_admin(admin["id"])
|
|
db.delete_admin(second["id"])
|
|
assert False, "should refuse deleting the last admin"
|
|
except ValueError:
|
|
pass
|
|
print("multi-admin OK")
|
|
|
|
ip = "203.0.113.9"
|
|
for _ in range(10):
|
|
db.record_login_attempt(ip, "password")
|
|
assert db.count_recent_login_attempts(ip, "password", minutes=15) >= 10
|
|
db.clear_login_attempts(ip, "password")
|
|
assert db.count_recent_login_attempts(ip, "password", minutes=15) == 0
|
|
print("rate-limit counters OK")
|
|
|
|
import datetime as dt
|
|
|
|
hold_sub = db.create_subscription(999, "n1", 30, "1m", source="bot")
|
|
original_expires = dt.datetime.fromisoformat(hold_sub["expires_at"])
|
|
assert db.hold_subscription(hold_sub["uuid"])
|
|
assert db.hold_subscription(hold_sub["uuid"]) is False
|
|
assert len(db.list_active_subscriptions(tg_id=999)) == 0, "held sub must not count as active"
|
|
with db.get_conn() as conn:
|
|
simulated = (dt.datetime.utcnow() - dt.timedelta(hours=5)).isoformat()
|
|
conn.execute("UPDATE subscriptions SET held_at=? WHERE uuid=?", (simulated, hold_sub["uuid"]))
|
|
resumed = db.resume_subscription(hold_sub["uuid"])
|
|
assert resumed["held_at"] is None
|
|
shift_hours = (dt.datetime.fromisoformat(resumed["expires_at"]) - original_expires).total_seconds() / 3600
|
|
assert 4.9 <= shift_hours <= 5.1, f"expected ~5h shift, got {shift_hours}"
|
|
assert len(db.list_active_subscriptions(tg_id=999)) == 1, "resumed sub must count as active again"
|
|
assert db.resume_subscription(hold_sub["uuid"]) is None
|
|
print("subscription hold/resume OK")
|
|
|
|
print("all v1.1.0 feature smoke tests passed")
|
|
PYEOF
|
|
|
|
- name: Smoke test live settings (.env-backed plans/toggles/HWID/credentials, no restart)
|
|
env:
|
|
BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
|
BOT_USERNAME: "x"
|
|
ADMIN_IDS: "1"
|
|
ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
|
|
PANEL_DOMAIN: "panel.test"
|
|
SUB_DOMAIN: "sub.test"
|
|
SITE_DOMAIN: "test"
|
|
XRAY_PUBLIC_KEY: "x"
|
|
XRAY_SHORT_ID_TCP: "x"
|
|
XRAY_SHORT_ID_GRPC: "x"
|
|
XRAY_SHORT_ID_XHTTP: "x"
|
|
run: |
|
|
python - << 'PYEOF'
|
|
import hashlib
|
|
import hmac
|
|
|
|
with open(".env", "a", encoding="utf-8") as f:
|
|
f.write("PLATEGA_SECRET=old_secret\n")
|
|
f.write("PLATEGA_ENABLED=true\n")
|
|
f.write("PLATEGA_MERCHANT_ID=m1\n")
|
|
|
|
import legal
|
|
import settings
|
|
import payments
|
|
|
|
plans = settings.get_plans_by_code()
|
|
assert plans["1m"]["price"] > 0, "default price should come from config before any .env override"
|
|
|
|
settings.set_plan_prices({"1m": 4242})
|
|
assert settings.get_plans_by_code()["1m"]["price"] == 4242, "price edit should apply live, no reimport"
|
|
assert settings.get_plans_by_code()["7d"]["price"] != 4242, "unrelated plan must stay untouched"
|
|
|
|
assert settings.get_hwid_settings()["enabled"] is False
|
|
legal.update_env_var("HWID_LIMIT_ENABLED", "true")
|
|
legal.update_env_var("HWID_FALLBACK_LIMIT", "9")
|
|
hwid = settings.get_hwid_settings()
|
|
assert hwid["enabled"] is True and hwid["fallback_limit"] == 9, "HWID settings should apply live"
|
|
|
|
body = b'{"transactionId":"t1","status":"CONFIRMED"}'
|
|
sig_old = hmac.new(b"old_secret", body, hashlib.sha256).hexdigest()
|
|
assert payments.verify_platega_signature(body, sig_old), "signature must verify against the current secret"
|
|
|
|
legal.update_env_var("PLATEGA_SECRET", "rotated_secret")
|
|
assert not payments.verify_platega_signature(body, sig_old), "OLD signature must be rejected right after rotation, same process, no restart"
|
|
sig_new = hmac.new(b"rotated_secret", body, hashlib.sha256).hexdigest()
|
|
assert payments.verify_platega_signature(body, sig_new), "NEW signature must verify immediately after rotation, same process, no restart"
|
|
|
|
for _ in range(5):
|
|
legal.update_env_var("HWID_FALLBACK_LIMIT", "9")
|
|
with open(".env", encoding="utf-8") as f:
|
|
lines = [l for l in f.readlines() if l.startswith("HWID_FALLBACK_LIMIT=")]
|
|
assert len(lines) == 1, "repeated writes to the same key must not duplicate .env lines"
|
|
|
|
print("live settings: prices/HWID/credential-rotation all apply with zero reimport OK")
|
|
PYEOF
|