fix(security): preserve and harden CRM links and appointment cancellation
This commit is contained in:
parent
050dc30d92
commit
157dfbf186
@ -113,7 +113,7 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
got = self.headers.get("X-Api-Key", "")
|
got = self.headers.get("X-Api-Key", "")
|
||||||
if not got:
|
if not got:
|
||||||
got = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
|
got = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
|
||||||
return bool(got) and hmac.compare_digest(got, BRIDGE_API_KEY)
|
return bool(got) and hmac.compare_digest(got.encode("utf-8"), BRIDGE_API_KEY.encode("utf-8"))
|
||||||
|
|
||||||
def _require_auth(self):
|
def _require_auth(self):
|
||||||
if self._authorized():
|
if self._authorized():
|
||||||
@ -527,8 +527,13 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(400, {"code": "bad_request", "message": "Invalid JSON"})
|
self.send_json(400, {"code": "bad_request", "message": "Invalid JSON"})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not isinstance(req_json, dict):
|
||||||
|
self.send_json(400, {"code": "bad_request", "message": "JSON object required"})
|
||||||
|
return
|
||||||
|
|
||||||
if path == "/appointments":
|
if path == "/appointments":
|
||||||
doctor_id = req_json.get("doctor_id", "")
|
doctor_id = req_json.get("doctor_id", "")
|
||||||
|
bitrix_lead_id = bsl_text(str(req_json.get("bitrix_lead_id", "")).strip(), 200)
|
||||||
branch_id = req_json.get("branch_id", "")
|
branch_id = req_json.get("branch_id", "")
|
||||||
patient_name = req_json.get("patient_name", "")
|
patient_name = req_json.get("patient_name", "")
|
||||||
patient_phone = req_json.get("patient_phone", "")
|
patient_phone = req_json.get("patient_phone", "")
|
||||||
@ -711,11 +716,23 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
Попытка Док.БИТ_ИсточникИнформации = Ист; Исключение КонецПопытки;
|
Попытка Док.БИТ_ИсточникИнформации = Ист; Исключение КонецПопытки;
|
||||||
КонецЕсли;
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если ЗначениеЗаполнено("{bitrix_lead_id}") Тогда
|
||||||
|
Попытка
|
||||||
|
Док.бит_Б24_СозданИзБитрикс24 = Истина;
|
||||||
|
Док.бит_Б24_БитриксИД = "{bitrix_lead_id}";
|
||||||
|
Док.бит_НомерКлиентаБ24 = "{bitrix_lead_id}";
|
||||||
|
Исключение
|
||||||
|
КонецПопытки;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
Док.Записать(РежимЗаписиДокумента.Запись);
|
Док.Записать(РежимЗаписиДокумента.Запись);
|
||||||
ID = НРег(XMLСтрока(Док.Ссылка));
|
ID = НРег(XMLСтрока(Док.Ссылка));
|
||||||
|
ID_B64 = Base64Строка(ПолучитьДвоичныеДанныеИзСтроки(ID));
|
||||||
|
|
||||||
Ответ = Новый Структура;
|
Ответ = Новый Структура;
|
||||||
Ответ.Вставить("appointment_id", ID);
|
Ответ.Вставить("appointment_id", ID);
|
||||||
|
Ответ.Вставить("appointment_id_b64", ID_B64);
|
||||||
|
Ответ.Вставить("number", Док.Номер);
|
||||||
Ответ.Вставить("status", "booked");
|
Ответ.Вставить("status", "booked");
|
||||||
Ответ.Вставить("start", Формат(Док.ДатаНачала, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00");
|
Ответ.Вставить("start", Формат(Док.ДатаНачала, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00");
|
||||||
Ответ.Вставить("end", Формат(Док.ДатаОкончания, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00");
|
Ответ.Вставить("end", Формат(Док.ДатаОкончания, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00");
|
||||||
@ -731,12 +748,242 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
КонецПопытки;
|
КонецПопытки;
|
||||||
"""
|
"""
|
||||||
data, err = call_1c(code)
|
data, err = call_1c(code)
|
||||||
|
if isinstance(data, dict) and "appointment_id_b64" in data:
|
||||||
|
import base64
|
||||||
|
try:
|
||||||
|
data["appointment_id"] = base64.b64decode(data["appointment_id_b64"]).decode("utf-8").strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if err or (isinstance(data, str) and data.startswith("ОШИБКА")):
|
if err or (isinstance(data, str) and data.startswith("ОШИБКА")):
|
||||||
self.send_json(500, {"code": "booking_failed", "message": err or data})
|
self.send_json(500, {"code": "booking_failed", "message": err or data})
|
||||||
else:
|
else:
|
||||||
self.send_json(201, data)
|
self.send_json(201, data)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if path == "/appointments/link-bitrix":
|
||||||
|
# Legacy linking supports UUIDs and document numbers: escape both.
|
||||||
|
appointment_id = bsl_text(str(req_json.get("appointment_id", "")).strip(), 200)
|
||||||
|
bitrix_lead_id = bsl_text(str(req_json.get("bitrix_lead_id", "")).strip(), 200)
|
||||||
|
if not appointment_id or not bitrix_lead_id:
|
||||||
|
self.send_json(400, {"code": "bad_request", "message": "appointment_id and bitrix_lead_id are required"})
|
||||||
|
return
|
||||||
|
|
||||||
|
code = f"""
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Неопределено;
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}"));
|
||||||
|
Исключение
|
||||||
|
КонецПопытки;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата());
|
||||||
|
Исключение
|
||||||
|
КонецПопытки;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Запрос = Новый Запрос;
|
||||||
|
Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ";
|
||||||
|
Запрос.УстановитьПараметр("Номер", "{appointment_id}");
|
||||||
|
Выб = Запрос.Выполнить().Выбрать();
|
||||||
|
Если Выб.Следующий() Тогда
|
||||||
|
ДокСсылка = Выб.Ссылка;
|
||||||
|
КонецЕсли;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Запрос = Новый Запрос;
|
||||||
|
Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ";
|
||||||
|
Выб = Запрос.Выполнить().Выбрать();
|
||||||
|
Пока Выб.Следующий() Цикл
|
||||||
|
СтрGUID = НРег(XMLСтрока(Выб.Ссылка));
|
||||||
|
Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда
|
||||||
|
ДокСсылка = Выб.Ссылка;
|
||||||
|
Прервать;
|
||||||
|
КонецЕсли;
|
||||||
|
КонецЦикла;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если ЗначениеЗаполнено(ДокСсылка) Тогда
|
||||||
|
ДокОбъект = ДокСсылка.ПолучитьОбъект();
|
||||||
|
Если ДокОбъект <> Неопределено Тогда
|
||||||
|
ДокОбъект.бит_Б24_СозданИзБитрикс24 = Истина;
|
||||||
|
ДокОбъект.бит_Б24_БитриксИД = "{bitrix_lead_id}";
|
||||||
|
ДокОбъект.бит_НомерКлиентаБ24 = "{bitrix_lead_id}";
|
||||||
|
ДокОбъект.Записать(РежимЗаписиДокумента.Запись);
|
||||||
|
Результат = "OK";
|
||||||
|
Иначе
|
||||||
|
Результат = "NOT_FOUND";
|
||||||
|
КонецЕсли;
|
||||||
|
Иначе
|
||||||
|
Результат = "NOT_FOUND";
|
||||||
|
КонецЕсли;
|
||||||
|
Исключение
|
||||||
|
Результат = "ОШИБКА: " + ОписаниеОшибки();
|
||||||
|
КонецПопытки;
|
||||||
|
"""
|
||||||
|
data, err = call_1c(code)
|
||||||
|
if err or (isinstance(data, str) and data.startswith("ОШИБКА")):
|
||||||
|
self.send_json(500, {"code": "link_failed", "message": err or data})
|
||||||
|
elif data == "NOT_FOUND":
|
||||||
|
self.send_json(404, {"code": "not_found", "message": "Запись в 1С не найдена"})
|
||||||
|
else:
|
||||||
|
self.send_json(200, {"status": "ok", "message": "Связано успешно"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if path == "/appointments/link-crm":
|
||||||
|
appointment_id = bsl_text(str(req_json.get("appointment_id", "")).strip(), 200)
|
||||||
|
crm_deal_id = bsl_text(str(req_json.get("crm_deal_id", "")).strip(), 200)
|
||||||
|
if not appointment_id or not crm_deal_id:
|
||||||
|
self.send_json(400, {"code": "bad_request", "message": "appointment_id and crm_deal_id are required"})
|
||||||
|
return
|
||||||
|
|
||||||
|
code = f"""
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Неопределено;
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}"));
|
||||||
|
Исключение
|
||||||
|
КонецПопытки;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата());
|
||||||
|
Исключение
|
||||||
|
КонецПопытки;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Запрос = Новый Запрос;
|
||||||
|
Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ";
|
||||||
|
Запрос.УстановитьПараметр("Номер", "{appointment_id}");
|
||||||
|
Выб = Запрос.Выполнить().Выбрать();
|
||||||
|
Если Выб.Следующий() Тогда
|
||||||
|
ДокСсылка = Выб.Ссылка;
|
||||||
|
КонецЕсли;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Запрос = Новый Запрос;
|
||||||
|
Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ";
|
||||||
|
Выб = Запрос.Выполнить().Выбрать();
|
||||||
|
Пока Выб.Следующий() Цикл
|
||||||
|
СтрGUID = НРег(XMLСтрока(Выб.Ссылка));
|
||||||
|
Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда
|
||||||
|
ДокСсылка = Выб.Ссылка;
|
||||||
|
Прервать;
|
||||||
|
КонецЕсли;
|
||||||
|
КонецЦикла;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если ЗначениеЗаполнено(ДокСсылка) Тогда
|
||||||
|
ДокОбъект = ДокСсылка.ПолучитьОбъект();
|
||||||
|
Если ДокОбъект <> Неопределено Тогда
|
||||||
|
Если Найти(ДокОбъект.Примечание, "{crm_deal_id}") = 0 Тогда
|
||||||
|
ДокОбъект.Примечание = СокрЛП(ДокОбъект.Примечание) + " [CRM: {crm_deal_id}]";
|
||||||
|
КонецЕсли;
|
||||||
|
ДокОбъект.Записать(РежимЗаписиДокумента.Запись);
|
||||||
|
Результат = "OK";
|
||||||
|
Иначе
|
||||||
|
Результат = "NOT_FOUND";
|
||||||
|
КонецЕсли;
|
||||||
|
Иначе
|
||||||
|
Результат = "NOT_FOUND";
|
||||||
|
КонецЕсли;
|
||||||
|
Исключение
|
||||||
|
Результат = "ОШИБКА: " + ОписаниеОшибки();
|
||||||
|
КонецПопытки;
|
||||||
|
"""
|
||||||
|
data, err = call_1c(code)
|
||||||
|
if err or (isinstance(data, str) and data.startswith("ОШИБКА")):
|
||||||
|
self.send_json(500, {"code": "link_failed", "message": err or data})
|
||||||
|
elif data == "NOT_FOUND":
|
||||||
|
self.send_json(404, {"code": "not_found", "message": "Запись в 1С не найдена"})
|
||||||
|
else:
|
||||||
|
self.send_json(200, {"status": "ok", "message": "Связано успешно с CRM"})
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"})
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
path = parsed.path.rstrip("/")
|
||||||
|
|
||||||
|
if not self._require_auth():
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = path.split("/")
|
||||||
|
if len(parts) == 3 and parts[1] == "appointments":
|
||||||
|
appointment_id = bsl_text(urllib.parse.unquote(parts[2]).strip(), 200)
|
||||||
|
code = f"""
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Неопределено;
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}"));
|
||||||
|
Исключение
|
||||||
|
КонецПопытки;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Попытка
|
||||||
|
ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата());
|
||||||
|
Исключение
|
||||||
|
КонецПопытки;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Запрос = Новый Запрос;
|
||||||
|
Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ";
|
||||||
|
Запрос.УстановитьПараметр("Номер", "{appointment_id}");
|
||||||
|
Выб = Запрос.Выполнить().Выбрать();
|
||||||
|
Если Выб.Следующий() Тогда
|
||||||
|
ДокСсылка = Выб.Ссылка;
|
||||||
|
КонецЕсли;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда
|
||||||
|
Запрос = Новый Запрос;
|
||||||
|
Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ";
|
||||||
|
Выб = Запрос.Выполнить().Выбрать();
|
||||||
|
Пока Выб.Следующий() Цикл
|
||||||
|
СтрGUID = НРег(XMLСтрока(Выб.Ссылка));
|
||||||
|
Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда
|
||||||
|
ДокСсылка = Выб.Ссылка;
|
||||||
|
Прервать;
|
||||||
|
КонецЕсли;
|
||||||
|
КонецЦикла;
|
||||||
|
КонецЕсли;
|
||||||
|
|
||||||
|
Если ЗначениеЗаполнено(ДокСсылка) Тогда
|
||||||
|
ДокОбъект = ДокСсылка.ПолучитьОбъект();
|
||||||
|
Если ДокОбъект <> Неопределено Тогда
|
||||||
|
Попытка
|
||||||
|
ДокОбъект.Состояние = Справочники.ВидыСостоянийЗаявок.Отменена;
|
||||||
|
Исключение
|
||||||
|
КонецПопытки;
|
||||||
|
ДокОбъект.ПометкаУдаления = Истина;
|
||||||
|
ДокОбъект.Записать(РежимЗаписиДокумента.Запись);
|
||||||
|
Результат = "OK";
|
||||||
|
Иначе
|
||||||
|
Результат = "NOT_FOUND";
|
||||||
|
КонецЕсли;
|
||||||
|
Иначе
|
||||||
|
Результат = "NOT_FOUND";
|
||||||
|
КонецЕсли;
|
||||||
|
Исключение
|
||||||
|
Результат = "ОШИБКА: " + ОписаниеОшибки();
|
||||||
|
КонецПопытки;
|
||||||
|
"""
|
||||||
|
data, err = call_1c(code)
|
||||||
|
if err or (isinstance(data, str) and data.startswith("ОШИБКА")):
|
||||||
|
self.send_json(500, {"code": "cancel_failed", "message": err or data})
|
||||||
|
elif data == "NOT_FOUND":
|
||||||
|
self.send_json(404, {"code": "not_found", "message": "Заявка в 1С не найдена"})
|
||||||
|
else:
|
||||||
|
self.send_json(200, {"status": "cancelled", "message": "Запись отменена в 1С"})
|
||||||
|
return
|
||||||
|
|
||||||
self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"})
|
self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"})
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
|
|||||||
@ -26,11 +26,12 @@ class ExtraSecurityTests(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
bridge.call_1c.reset_mock()
|
bridge.call_1c.reset_mock()
|
||||||
|
|
||||||
def post(self, body):
|
def post(self, body, path="/appointments", headers=None, method=None):
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
f"http://127.0.0.1:{server.server_port}/appointments",
|
f"http://127.0.0.1:{server.server_port}" + path,
|
||||||
data=json.dumps(body).encode(),
|
data=json.dumps(body).encode(),
|
||||||
headers={"X-Api-Key": "test-only"},
|
headers={"X-Api-Key": "test-only"} if headers is None else headers,
|
||||||
|
method=method,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=5) as response:
|
with urllib.request.urlopen(request, timeout=5) as response:
|
||||||
@ -71,6 +72,36 @@ class ExtraSecurityTests(unittest.TestCase):
|
|||||||
self.assertIn('Представление = "+7999";', code)
|
self.assertIn('Представление = "+7999";', code)
|
||||||
self.assertIn('"Онлайн-запись: Test""comment";', code)
|
self.assertIn('"Онлайн-запись: Test""comment";', code)
|
||||||
|
|
||||||
|
def test_non_object_json_rejected(self):
|
||||||
|
self.assertEqual(self.post([]), 400)
|
||||||
|
bridge.call_1c.assert_not_called()
|
||||||
|
|
||||||
|
def test_non_ascii_bad_key_rejected(self):
|
||||||
|
self.assertEqual(self.post({}, headers={"X-Api-Key": "é"}), 401)
|
||||||
|
bridge.call_1c.assert_not_called()
|
||||||
|
|
||||||
|
def test_bitrix_link_preserved_and_escaped(self):
|
||||||
|
self.assertEqual(self.post({"appointment_id": 'id"value', "bitrix_lead_id": 'lead"value'}, "/appointments/link-bitrix"), 200)
|
||||||
|
code = bridge.call_1c.call_args.args[0]
|
||||||
|
self.assertIn('БитриксИД = "lead""value";', code)
|
||||||
|
self.assertIn('УникальныйИдентификатор("id""value")', code)
|
||||||
|
|
||||||
|
def test_crm_link_preserved_and_escaped(self):
|
||||||
|
self.assertEqual(self.post({"appointment_id": "00001234", "crm_deal_id": 'deal"value'}, "/appointments/link-crm"), 200)
|
||||||
|
code = bridge.call_1c.call_args.args[0]
|
||||||
|
self.assertIn('[CRM: deal""value]', code)
|
||||||
|
self.assertIn('НайтиПоНомеру("00001234",', code)
|
||||||
|
|
||||||
|
def test_delete_requires_auth_before_1c(self):
|
||||||
|
self.assertEqual(self.post({}, "/appointments/test", headers={}, method="DELETE"), 401)
|
||||||
|
bridge.call_1c.assert_not_called()
|
||||||
|
|
||||||
|
def test_delete_preserved_and_escaped(self):
|
||||||
|
self.assertEqual(self.post({}, "/appointments/test%22value", method="DELETE"), 200)
|
||||||
|
code = bridge.call_1c.call_args.args[0]
|
||||||
|
self.assertIn('УникальныйИдентификатор("test""value")', code)
|
||||||
|
self.assertIn('ПометкаУдаления = Истина;', code)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -13,7 +13,12 @@ Applied to the bridge only. API and PostgreSQL must not be rebuilt or restarted.
|
|||||||
- Existing dana-medical_default network is external to Compose: prevents
|
- Existing dana-medical_default network is external to Compose: prevents
|
||||||
recreation of the live API/DB network. Existing subnet: 172.20.0.0/16.
|
recreation of the live API/DB network. Existing subnet: 172.20.0.0/16.
|
||||||
- Runtime Python file owned by UID10001 regardless of build-context permissions.
|
- Runtime Python file owned by UID10001 regardless of build-context permissions.
|
||||||
- 19 supplied smoke assertions and 6 additional isolated security regressions.
|
- Existing Bitrix/CRM linking, appointment cancellation and response fields are
|
||||||
|
preserved from the original running source. The package's full replacement
|
||||||
|
file predates these features; only its targeted patch was applied to the base.
|
||||||
|
- Legacy linking/cancellation support both UUID and document-number lookup;
|
||||||
|
their BSL literals are escaped without removing that existing compatibility.
|
||||||
|
- 19 supplied smoke assertions and 12 additional isolated security regressions.
|
||||||
|
|
||||||
## Host firewall: prepared, not installed
|
## Host firewall: prepared, not installed
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user