Compare commits

..
9 Commits
Author SHA1 Message Date
richjohnrambo bc101b3278 fix: query tenant-sharded private messages
Deploy im-server production / deploy (push) Successful in 7m7s
2026-10-21 12:40:40 +08:00
richjohnrambo 39b0392545 feat: add trusted customer service friendships
Deploy im-server production / deploy (push) Successful in 11m38s
2026-10-20 22:24:34 +08:00
Your Name 93a6ff0d03 fix(ci): keep im deploy scoped to service
Deploy im-server production / deploy (push) Successful in 6m19s
2026-10-20 17:56:31 +08:00
richjohnrambo 9c10862a6d fix: recreate Caddy after IM CORS update
Deploy im-server production / deploy (push) Successful in 7m58s
2026-10-19 14:34:13 +08:00
richjohnrambo 5197960e58 fix: remove conflicting production IM CORS header
Deploy im-server production / deploy (push) Successful in 7m23s
2026-10-19 14:24:57 +08:00
richjohnrambo fe5f870b6b ci: print production IM Caddy route
Deploy im-server production / deploy (push) Successful in 7m25s
2026-10-19 14:16:11 +08:00
richjohnrambo 5b270860e7 ci: inspect production IM CORS routing
Deploy im-server production / deploy (push) Successful in 8m9s
2026-10-19 14:06:20 +08:00
Your Name 209f105b12 fix: honor admin internal api toggle
Deploy im-server production / deploy (push) Successful in 4m25s
2026-10-18 20:24:39 +08:00
Your Name 5d49008960 ci: deploy pro with docker compose
Deploy im-server production / deploy (push) Failing after 8m46s
2026-10-18 20:11:07 +08:00
10 changed files with 242 additions and 103 deletions
+53 -102
View File
@@ -1,118 +1,69 @@
name: Deploy im-server (k3s test cluster)
name: Deploy im-server production
on:
push:
branches:
- pro
workflow_dispatch:
inputs:
image_tag:
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)'
required: false
concurrency:
group: production-im-server
cancel-in-progress: false
permissions:
contents: read
env:
REGISTRY: git.olns.shop
IMAGE: git.olns.shop/pro/im_server
SERVICE: im-server
DEPLOY_HOST: 145.223.69.9
DEPLOY_USER: olns-deploy
jobs:
deploy:
runs-on: deploy-host
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: 确定镜像tag
id: vars
- name: Log in to Gitea Container Registry
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
TAG="${{ github.event.inputs.image_tag }}"
[ -z "$TAG" ] && TAG=test
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
set -euo pipefail
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
- name: 登录镜像仓库
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
- name: 构建并推送镜像(从本仓库源码编译,不是官方预编译包)
- name: Build and push image
env:
GITHUB_SHA: ${{ github.sha }}
run: |
set -euo pipefail
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
docker build -f docker/tinode/Dockerfile.build \
-t git.118im.com/im_team/im-server:${{ steps.vars.outputs.tag }} .
docker push git.118im.com/im_team/im-server:${{ steps.vars.outputs.tag }}
-t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
docker push "$IMAGE:pro"
docker push "$IMAGE:$SHORT_SHA"
- name: 部署到k3s测试集群
- name: Deploy Compose service
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl set resources statefulset/im-server -n im-server-system -c im-server \
--requests=cpu=2,memory=512Mi --limits=cpu=2,memory=1Gi
kubectl set env statefulset/im-server -n im-server-system -c im-server \
CLUSTER_PROXY_TO_MASTER_BUFFER=4096 \
CLUSTER_PROXY_TO_MASTER_WORKERS=16 \
CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS=300 \
CLUSTER_ROUTE_RETRIES=3 \
CLUSTER_ROUTE_RETRY_BACKOFF_MS=25 \
MYSQL_MAX_OPEN_CONNS=24 \
MYSQL_MAX_IDLE_CONNS=8 \
MYSQL_REGISTRY_MAX_OPEN_CONNS=4 \
MYSQL_REGISTRY_MAX_IDLE_CONNS=2 \
MYSQL_CONN_MAX_LIFETIME_SECONDS=1800
kubectl patch statefulset/im-server -n im-server-system --type=strategic -p \
'{"spec":{"template":{"spec":{"containers":[{"name":"im-server","readinessProbe":{"httpGet":{"path":"/healthz","port":6060},"timeoutSeconds":3,"periodSeconds":10,"failureThreshold":3},"livenessProbe":{"httpGet":{"path":"/livez","port":6060},"timeoutSeconds":3,"periodSeconds":20,"failureThreshold":3}}]}}}}'
if kubectl auth can-i get services -n im-server-system >/dev/null 2>&1 && \
kubectl auth can-i patch services -n im-server-system >/dev/null 2>&1; then
kubectl patch service/im-server -n im-server-system --type=merge -p \
'{"spec":{"sessionAffinity":"None"}}'
else
echo "::warning::ci-deployer cannot get/patch Service; skipped sessionAffinity update"
fi
if kubectl auth can-i get ingresses.networking.k8s.io -n im-server-system >/dev/null 2>&1 && \
kubectl auth can-i patch ingresses.networking.k8s.io -n im-server-system >/dev/null 2>&1 && \
kubectl get ingress/im-server -n im-server-system >/dev/null 2>&1; then
kubectl annotate ingress/im-server -n im-server-system --overwrite \
nginx.ingress.kubernetes.io/load-balance=least_conn
else
echo "::warning::ci-deployer cannot manage Ingress/im-server or it does not exist; skipped least_conn update"
fi
kubectl rollout restart statefulset/im-server -n im-server-system
if ! kubectl rollout status statefulset/im-server -n im-server-system --timeout=300s; then
echo "::error::im-server rollout did not become ready within 300 seconds"
kubectl get statefulset/im-server -n im-server-system -o wide || true
kubectl get pods -n im-server-system -l app=im-server -o wide || true
kubectl describe statefulset/im-server -n im-server-system || true
for pod in $(kubectl get pods -n im-server-system -l app=im-server -o name 2>/dev/null); do
echo "===== ${pod} describe ====="
kubectl describe -n im-server-system "${pod}" || true
echo "===== ${pod} current log ====="
kubectl logs -n im-server-system "${pod}" -c im-server --tail=200 || true
echo "===== ${pod} previous log ====="
kubectl logs -n im-server-system "${pod}" -c im-server --previous --tail=200 || true
done
if kubectl auth can-i list events -n im-server-system >/dev/null 2>&1; then
echo "===== recent events ====="
kubectl get events -n im-server-system --sort-by=.lastTimestamp | tail -100 || true
fi
exit 1
fi
echo "Waiting for ArgoCD reconciliation before validating effective settings"
sleep 60
request_cpu="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.requests.cpu}')"
request_memory="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.requests.memory}')"
limit_cpu="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.limits.cpu}')"
limit_memory="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.limits.memory}')"
liveness="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].livenessProbe.httpGet.path}')"
mysql_max_open="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_MAX_OPEN_CONNS")].value}')"
mysql_max_idle="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_MAX_IDLE_CONNS")].value}')"
mysql_registry_max_open="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_REGISTRY_MAX_OPEN_CONNS")].value}')"
mysql_registry_max_idle="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_REGISTRY_MAX_IDLE_CONNS")].value}')"
mysql_conn_max_lifetime="$(kubectl get statefulset/im-server -n im-server-system -o \
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_CONN_MAX_LIFETIME_SECONDS")].value}')"
echo "effective requests=${request_cpu}/${request_memory} limits=${limit_cpu}/${limit_memory} liveness=${liveness} mysql=${mysql_max_open}/${mysql_max_idle} registry=${mysql_registry_max_open}/${mysql_registry_max_idle} lifetime=${mysql_conn_max_lifetime}"
if [ "${request_cpu}" != "2" ] || [ "${request_memory}" != "512Mi" ] || \
[ "${limit_cpu}" != "2" ] || [ "${limit_memory}" != "1Gi" ] || \
[ "${liveness}" != "/livez" ] || [ "${mysql_max_open}" != "24" ] || \
[ "${mysql_max_idle}" != "8" ] || [ "${mysql_registry_max_open}" != "4" ] || \
[ "${mysql_registry_max_idle}" != "2" ] || [ "${mysql_conn_max_lifetime}" != "1800" ]; then
echo "::error::ArgoCD reverted StatefulSet resources/probes/MySQL pools. Apply deploy/argocd/im-server-test/statefulset-performance-patch.yaml to the im-server-test source."
exit 1
fi
set -euo pipefail
install -d -m 700 "$HOME/.ssh"
KEY_FILE="$(mktemp "$HOME/.ssh/olns-deploy.XXXXXX")"
cleanup() {
shred -u "$KEY_FILE" 2>/dev/null || true
}
trap cleanup EXIT
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_FILE"
chmod 600 "$KEY_FILE"
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/known_hosts"
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
"docker login git.olns.shop -u admin --password-stdin"
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
"cd /opt/olns && docker compose pull im-server && docker compose up -d --no-deps --wait im-server"
+1 -1
View File
@@ -177,7 +177,7 @@
}
},
"admin_internal_api": {
"enabled": true,
"enabled": $ADMIN_INTERNAL_API_ENABLED,
"base_url": "$ADMIN_INTERNAL_API_BASE_URL",
"service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
"secret": "$ADMIN_INTERNAL_API_SECRET",
+13
View File
@@ -72,6 +72,19 @@ func TestMessageQuerySpecsExposeAdminFields(t *testing.T) {
}
}
func TestPrivateMessageQueryUsesTenantShardTables(t *testing.T) {
spec := privateMessagesInternalQuerySpecForTenant(types.TenantID(9))
query := strings.ToLower(spec.From)
for _, table := range []string{"im_009_messages", "im_009_subscriptions"} {
if !strings.Contains(query, table) {
t.Fatalf("private message query must use tenant shard table %q: %s", table, query)
}
}
if strings.Contains(query, "from im_messages ") || strings.Contains(query, "from im_subscriptions ") {
t.Fatalf("private message query must not use legacy shared tables: %s", query)
}
}
func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) {
req := httptest.NewRequest("GET", "/?seq_id=42&sender_username=alice&sender_nickname=%E5%B0%8F%E6%98%8E&group_name=%E6%B5%8B%E8%AF%95%E7%BE%A4", nil)
query, _, args, _, _, err := buildInternalAdminListQuery(req, groupMessagesInternalQuerySpec(), "", types.TenantID(1))
+6
View File
@@ -147,6 +147,7 @@ func (s *testHTTPInfrastructureStore) MarkKafkaDeadLetterReplay(_ types.TenantID
type testRelationsStore struct {
context *types.RelationshipContext
err error
directAdded bool
created *types.FriendRequest
blocked *types.UserBlock
deleted bool
@@ -964,6 +965,11 @@ func (s *testRelationsStore) CreateFriendRequest(_ types.TenantID, req *types.Fr
return req, nil
}
func (s *testRelationsStore) AddFriendDirect(_ types.TenantID, _, _ types.Uid, _ time.Time) error {
s.directAdded = true
return s.err
}
func (s *testRelationsStore) ListFriendRequests(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendRequestListItem, string, error) {
return s.friendRequests, "", nil
}
+11
View File
@@ -127,6 +127,9 @@ func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request,
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
return
}
if resource == "private-messages" {
spec = privateMessagesInternalQuerySpecForTenant(tenantID)
}
if resource == "group-messages" {
var err error
spec, err = groupMessagesInternalQuerySpecForTenant(tenantID)
@@ -400,6 +403,14 @@ func privateMessagesInternalQuerySpec() *internalQuerySpec {
}
}
func privateMessagesInternalQuerySpecForTenant(tenantID types.TenantID) *internalQuerySpec {
spec := privateMessagesInternalQuerySpec()
prefix := fmt.Sprintf("im_%03d_", tenantID)
spec.From = strings.Replace(spec.From, "FROM im_messages ", "FROM "+prefix+"messages ", 1)
spec.From = strings.ReplaceAll(spec.From, "FROM im_subscriptions ", "FROM "+prefix+"subscriptions ")
return spec
}
func groupMessagesInternalQuerySpec() *internalQuerySpec {
status := `CASE WHEN aa.action='delete' THEN 'deleted' WHEN aa.action='hide' THEN 'hidden' WHEN m.status='recalled' THEN 'recalled' WHEN m.status IN ('deleted','blocked') THEN m.status ELSE 'normal' END`
contentType := `CASE m.msg_type WHEN 'call' THEN 'system' WHEN 'system_notice' THEN 'system' WHEN 'contact_card' THEN 'card' WHEN 'forward_bundle' THEN 'card' WHEN 'link' THEN 'text' ELSE m.msg_type END`
+30
View File
@@ -116,6 +116,8 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
serveInternalUserCreate(wrt, req, tenant.ID)
case req.Method == http.MethodPost && len(parts) == 5 && parts[2] == "users" && parts[4] == "action":
serveInternalUserAction(wrt, req, tenant.ID, parts[3])
case req.Method == http.MethodPost && len(parts) == 4 && parts[2] == "friends" && parts[3] == "direct":
serveInternalFriendDirectAdd(wrt, req, tenant.ID)
case req.Method == http.MethodPost && len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release":
serveInternalLoginLockoutRelease(wrt, req, tenant.ID)
case req.Method == http.MethodGet && len(parts) == 3 && parts[2] == "users":
@@ -166,9 +168,37 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
func isInternalAdminPostRoute(parts []string) bool {
return (len(parts) == 3 && parts[2] == "users") ||
(len(parts) == 5 && parts[2] == "users" && parts[4] == "action") ||
(len(parts) == 4 && parts[2] == "friends" && parts[3] == "direct") ||
(len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release")
}
type internalDirectFriendRequest struct {
UserID string `json:"user_id"`
PeerID string `json:"peer_user_id"`
}
func serveInternalFriendDirectAdd(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
var body internalDirectFriendRequest
if err := json.NewDecoder(io.LimitReader(req.Body, 1<<20)).Decode(&body); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
userID := types.ParseUserId(strings.TrimSpace(body.UserID))
peerID := types.ParseUserId(strings.TrimSpace(body.PeerID))
if userID.IsZero() || peerID.IsZero() || userID == peerID {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed user IDs", nil)
return
}
if err := store.Relations.AddFriendDirect(tenantID, userID, peerID, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"relationship": "friend",
"topic": userID.P2PName(peerID),
})
}
type internalUserCreateRequest struct {
Username string `json:"username"`
Nickname string `json:"nickname"`
+30
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -37,6 +38,35 @@ func (internalAdminQueryNotFound) QueryOne(types.TenantID, string, []any) (map[s
return nil, types.ErrNotFound
}
func TestServeInternalFriendDirectAddCreatesRelationshipWithoutRequest(t *testing.T) {
oldRelations := store.Relations
relations := &testRelationsStore{}
store.Relations = relations
t.Cleanup(func() { store.Relations = oldRelations })
req := httptest.NewRequest(http.MethodPost, "/internal/admin-query/tenants/default/friends/direct", strings.NewReader(`{"user_id":"usrAQAAAAAAAAA","peer_user_id":"usrAgAAAAAAAAA"}`))
wrt := httptest.NewRecorder()
serveInternalFriendDirectAdd(wrt, req, types.TenantID(1))
if wrt.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", wrt.Code, http.StatusOK, wrt.Body.String())
}
if !relations.directAdded || relations.created != nil {
t.Fatalf("direct_added=%v friend_request=%v, want direct add and no request", relations.directAdded, relations.created)
}
var payload struct {
Data struct {
Relationship string `json:"relationship"`
} `json:"data"`
}
if err := json.Unmarshal(wrt.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.Data.Relationship != types.RelationshipFriend {
t.Fatalf("relationship = %q, want %q", payload.Data.Relationship, types.RelationshipFriend)
}
}
func TestServeInternalUserQueryMissingUserReturnsNotFound(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
+36
View File
@@ -3065,6 +3065,7 @@ func validUserProfilePatch(patch types.UserProfilePatch) bool {
}
type RelationsPersistenceInterface interface {
AddFriendDirect(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) error
CreateFriendRequest(tenantID types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error)
ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error)
CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error)
@@ -3153,6 +3154,41 @@ func (relationsMapper) CreateFriendRequest(tenantID types.TenantID, req *types.F
return tenantAdp.TenantFriendRequestCreate(tenantID, req)
}
// AddFriendDirect creates a symmetric friendship for trusted server-side
// workflows, such as opening a customer-service conversation. It deliberately
// does not create a friend-request record, but it still honors either user's
// block list.
func (relationsMapper) AddFriendDirect(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) error {
if userID.IsZero() || peerID.IsZero() || userID == peerID {
return types.ErrMalformed
}
if _, err := Users.Get(tenantID, userID); err != nil {
return err
}
if _, err := Users.Get(tenantID, peerID); err != nil {
return err
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
relationship, err := relationshipContext(tenantAdp, tenantID, userID, peerID, "")
if err != nil {
return err
}
if relationship.Relationship == types.RelationshipBlockedByMe || relationship.Relationship == types.RelationshipBlockedByPeer {
return types.ErrPermissionDenied
}
if now.IsZero() {
now = time.Now().UTC()
}
if err = tenantAdp.TenantFriendshipUpsertPair(tenantID, userID, peerID, userID.P2PName(peerID), now); err != nil {
return err
}
cacheFriendPair(tenantID, userID, peerID)
return nil
}
func (relationsMapper) ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error) {
if uid.IsZero() {
return nil, "", types.ErrMalformed
+45
View File
@@ -0,0 +1,45 @@
from pathlib import Path
import unittest
WORKFLOW = Path(__file__).resolve().parents[1] / ".gitea/workflows/deploy.yml"
class DeployWorkflowContractTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.workflow = WORKFLOW.read_text(encoding="utf-8")
def test_targets_pro_branch_and_single_runner(self):
self.assertIn("push:", self.workflow)
self.assertIn("- pro", self.workflow)
self.assertIn("workflow_dispatch:", self.workflow)
self.assertIn("runs-on: deploy-host", self.workflow)
self.assertIn("concurrency:", self.workflow)
def test_builds_and_pushes_pro_image(self):
self.assertIn("git.olns.shop/pro/im_server", self.workflow)
self.assertIn("docker push", self.workflow)
self.assertIn(":pro", self.workflow)
def test_deploys_compose_service_with_pinned_host_key(self):
self.assertIn("DEPLOY_SSH_KEY", self.workflow)
self.assertIn("DEPLOY_KNOWN_HOSTS", self.workflow)
self.assertIn("StrictHostKeyChecking=yes", self.workflow)
self.assertIn("docker compose pull im-server", self.workflow)
self.assertIn("docker compose up -d --no-deps --wait im-server", self.workflow)
def test_removes_old_kubernetes_and_registry_targets(self):
self.assertNotIn("kubectl", self.workflow)
self.assertNotIn("KUBECONFIG", self.workflow)
self.assertNotIn("git.118im.com", self.workflow)
def test_deploy_does_not_mutate_shared_caddy_configuration(self):
self.assertNotIn("Ensure production IM CORS routing", self.workflow)
self.assertNotIn("Caddyfile", self.workflow)
self.assertNotIn("perl -0pi", self.workflow)
self.assertNotIn("force-recreate caddy", self.workflow)
if __name__ == "__main__":
unittest.main()
+17
View File
@@ -0,0 +1,17 @@
from pathlib import Path
import unittest
TEMPLATE = Path(__file__).resolve().parents[1] / "docker/tinode/config.template"
class TinodeConfigTemplateTest(unittest.TestCase):
def test_admin_internal_api_enabled_comes_from_environment(self):
template = TEMPLATE.read_text(encoding="utf-8")
section = template.split('"admin_internal_api": {', 1)[1].split("},", 1)[0]
self.assertIn('"enabled": $ADMIN_INTERNAL_API_ENABLED', section)
if __name__ == "__main__":
unittest.main()