feat(matrix): MAS migration helpers + 2 flows + 15 issues + capability group
Helper functions (matrix-mas capability group): - mas_client_register_bash_infra: register/sync OAuth clients via mas-cli - mas_syn2mas_migration_bash_infra: dry-run + apply user migration to MAS - synapse_msc3861_enable_go_infra: edit homeserver.yaml MSC3861 block (with diff) - wellknown_oidc_patch_go_infra: patch well-known JSON with msc2965.authentication - synapse_login_flows_check_go_infra: health-check post-migration login flows Flows + issues for custom Matrix clients (PC + Android): - 0010 matrix-client-pc: Wails + React+Mantine (issues 0147-0153) - 0011 matrix-client-android: Kotlin + Compose (issues 0154-0161) - 0162 enable MAS as auth provider (Synapse delegate) — EXECUTED on VPS - 0163 custom admin panel propio (sustituye synapse-admin) Production state (organic-machine.com): - Synapse migrated SQLite -> Postgres - MSC3861 active, password_config disabled - 21 users + 41 access_tokens migrated via syn2mas - 4 MAS clients registered (element, matrix_pc, matrix_android, admin_panel) - synapse-admin container removed + Coolify route deleted - well-known patched with org.matrix.msc2965.authentication Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loginFlowsJSON builds a minimal /_matrix/client/v3/login response body.
|
||||
func loginFlowsJSON(flows []loginFlow) string {
|
||||
b, _ := json.Marshal(loginFlowsResponse{Flows: flows})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// masFlows returns a typical post-migration response: only SSO with one IdP.
|
||||
func masFlows(idpID string) []loginFlow {
|
||||
return []loginFlow{
|
||||
{
|
||||
Type: "m.login.sso",
|
||||
IdentityProviders: []idpProvider{
|
||||
{ID: idpID, Name: "MAS"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// legacyFlows returns a pre-migration response: password + application_service.
|
||||
func legacyFlows() []loginFlow {
|
||||
return []loginFlow{
|
||||
{Type: "m.login.password"},
|
||||
{Type: "m.login.application_service"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynapseLoginFlowsCheck(t *testing.T) {
|
||||
// Disable real sleep during tests
|
||||
origSleep := sleepSeconds
|
||||
sleepSeconds = func(int) {}
|
||||
t.Cleanup(func() { sleepSeconds = origSleep })
|
||||
|
||||
t.Run("SSO + IdP expected -> success on first attempt", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(loginFlowsJSON(masFlows("oidc-mas"))))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 5,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 5,
|
||||
}
|
||||
|
||||
res, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if !res.SsoPresent {
|
||||
t.Error("SsoPresent should be true")
|
||||
}
|
||||
if !res.IdpFound {
|
||||
t.Error("IdpFound should be true")
|
||||
}
|
||||
if res.PasswordEnabled {
|
||||
t.Error("PasswordEnabled should be false")
|
||||
}
|
||||
if res.AttemptsUsed != 1 {
|
||||
t.Errorf("expected 1 attempt, got %d", res.AttemptsUsed)
|
||||
}
|
||||
if len(res.LastResponseJSON) == 0 {
|
||||
t.Error("LastResponseJSON should not be empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy response then SSO on 3rd attempt -> success after retries", func(t *testing.T) {
|
||||
var callCount int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := int(atomic.AddInt32(&callCount, 1))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if n < 3 {
|
||||
w.Write([]byte(loginFlowsJSON(legacyFlows())))
|
||||
} else {
|
||||
w.Write([]byte(loginFlowsJSON(masFlows("oidc-mas"))))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 10,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 5,
|
||||
}
|
||||
|
||||
res, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if res.AttemptsUsed != 3 {
|
||||
t.Errorf("expected 3 attempts, got %d", res.AttemptsUsed)
|
||||
}
|
||||
if !res.SsoPresent {
|
||||
t.Error("SsoPresent should be true")
|
||||
}
|
||||
if res.PasswordEnabled {
|
||||
t.Error("PasswordEnabled should be false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("response never changes -> error after maxRetries", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(loginFlowsJSON(legacyFlows())))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 3,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 5,
|
||||
}
|
||||
|
||||
res, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error after max retries, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "MAS migration not confirmed") {
|
||||
t.Errorf("expected 'MAS migration not confirmed' in error message, got: %v", err)
|
||||
}
|
||||
if res.AttemptsUsed != 3 {
|
||||
t.Errorf("expected 3 attempts used, got %d", res.AttemptsUsed)
|
||||
}
|
||||
if !res.PasswordEnabled {
|
||||
t.Error("PasswordEnabled should be true (legacy still active)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTP timeout -> error", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Deliberately hang longer than the 1s timeout
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
ExpectedSsoIdpID: "oidc-mas",
|
||||
MaxRetries: 1,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 1,
|
||||
}
|
||||
|
||||
_, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on timeout, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "synapse_login_flows_check") {
|
||||
t.Errorf("expected error to contain function name, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed JSON -> error", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{not valid json`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := SynapseLoginFlowsCheckConfig{
|
||||
HomeserverURL: srv.URL,
|
||||
MaxRetries: 1,
|
||||
RetryDelaySeconds: 0,
|
||||
HttpTimeoutSeconds: 5,
|
||||
}
|
||||
|
||||
_, err := SynapseLoginFlowsCheck(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on malformed JSON, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "json unmarshal") {
|
||||
t.Errorf("expected json unmarshal error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user