diff --git a/justfile b/justfile index e7d1de7..46d2210 100644 --- a/justfile +++ b/justfile @@ -41,9 +41,22 @@ _build-rust-sdk dir: #!/usr/bin/env bash set -euxo pipefail - cd "{{ dir }}" + cd "{{ dir }}" - cargo build -p matrix-sdk-ffi --features 'sentry, _only-for-testing-disable-megolm-minimum-rotation-period-ms' + # The `_disable-minimum-rotation-period-ms` feature lives on matrix-sdk-crypto, + # not on matrix-sdk-ffi, so it cannot be passed via `--features` here. Patch the + # workspace Cargo.toml to inject it into the crypto dep (mirroring upstream + # rebuild_rust_sdk.sh), and restore both files afterwards. + cp Cargo.toml Cargo.toml.backup + cp Cargo.lock Cargo.lock.backup + trap 'mv -f Cargo.toml.backup Cargo.toml; mv -f Cargo.lock.backup Cargo.lock' EXIT + sed -i.bak 's#matrix-sdk-crypto = {#matrix-sdk-crypto = {features = ["_disable-minimum-rotation-period-ms"],#' Cargo.toml + rm -f Cargo.toml.bak + if ! grep -q "_disable-minimum-rotation-period-ms" Cargo.toml; then + echo "Failed to inject _disable-minimum-rotation-period-ms feature" >&2 + exit 1 + fi + cargo build -p matrix-sdk-ffi --features 'sentry' uniffi-bindgen-go -o {{ COMPLEMENT_DIR }}/internal/api/rust --config {{ COMPLEMENT_DIR }}/uniffi.toml --library ./target/debug/libmatrix_sdk_ffi.a # Add the cgo LDFLAGS directive to the generated bindings. diff --git a/tests/main_test.go b/tests/main_test.go index 710856d..35dc2b3 100644 --- a/tests/main_test.go +++ b/tests/main_test.go @@ -1,6 +1,7 @@ package tests import ( + "os" "testing" "github.com/matrix-org/complement-crypto/internal/cc" @@ -15,10 +16,30 @@ var ( // Main entry point when users run `go test`. Defined in https://pkg.go.dev/testing#hdr-Main func TestMain(m *testing.M) { instance = cc.NewInstance(config.NewComplementCryptoConfigFromEnvVars("./mitmproxy_addons")) - instance.TestMain(m, "crypto") + namespace := resolveNamespace(os.Getenv("COMPLEMENT_CRYPTO_NAMESPACE")) + instance.TestMain(m, namespace) } +// resolveNamespace returns the namespace applied to every Docker network/container +// this suite deploys (e.g. `complement_..hs1`). It must be +// unique per `go test` process so concurrent sharded runs get fully isolated +// homeservers instead of colliding on the same name. Defaults to `crypto` for a +// single (unsharded) run. An empty value (or the default) is fine, but any value +// containing characters outside [A-Za-z0-9_.-] would produce invalid Docker names +// and fail with a low-level Docker error, so we reject it here with a clear message. +func resolveNamespace(raw string) string { + if raw == "" { + raw = "crypto" + } + for _, r := range raw { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '.' || r == '-') { + panic("COMPLEMENT_CRYPTO_NAMESPACE must contain only characters in [A-Za-z0-9_.-], got: " + raw) + } + } + return raw +} + // Instance returns the test instance. Guaranteed to be non-nil if called in a test, // because TestMain would have been called before the test runs. func Instance() *cc.Instance { diff --git a/tests/namespace_test.go b/tests/namespace_test.go new file mode 100644 index 0000000..68199b1 --- /dev/null +++ b/tests/namespace_test.go @@ -0,0 +1,36 @@ +package tests + +import "testing" + +func TestResolveNamespace(t *testing.T) { + // acceptable values pass through unchanged + valid := []string{"crypto", "shard_01", "a.B-c9", "_", ".-"} + for _, v := range valid { + if got := resolveNamespace(v); got != v { + t.Fatalf("resolveNamespace(%q) = %q, want %q", v, got, v) + } + } + + // empty defaults to "crypto" + if got := resolveNamespace(""); got != "crypto" { + t.Fatalf("resolveNamespace(\"\") = %q, want %q", got, "crypto") + } + + // invalid values are rejected with a clear panic + invalid := []string{"crypto name", "shard/01", "a:b", "ns$", "shard,2", "a=B"} + for _, v := range invalid { + func() { + defer func() { + rec := recover() + if rec == nil { + t.Fatalf("resolveNamespace(%q) did not panic", v) + } + want := "COMPLEMENT_CRYPTO_NAMESPACE must contain only characters in [A-Za-z0-9_.-], got: " + v + if rec != want { + t.Fatalf("resolveNamespace(%q) panic = %q, want %q", v, rec, want) + } + }() + resolveNamespace(v) + }() + } +} diff --git a/tests/one_time_keys_test.go b/tests/one_time_keys_test.go index 4bcbd60..1cf800d 100644 --- a/tests/one_time_keys_test.go +++ b/tests/one_time_keys_test.go @@ -21,9 +21,19 @@ import ( "github.com/tidwall/gjson" ) +// mustClaimFallbackKey claims the fallback key for the target user. +// The SDK uploads its fallback key asynchronously after learning it needs +// one (via device_unused_fallback_key_types in the sync response), so it +// may not have landed yet when the test first claims it. Retry until it +// appears rather than failing on the first (empty) claim. func mustClaimFallbackKey(t *testing.T, claimer *client.CSAPI, target *cc.User) (fallbackKeyID string, keyJSON gjson.Result) { t.Helper() - res := claimer.MustDo(t, "POST", []string{ + // The SDK uploads its fallback key asynchronously after learning it needs + // one (via device_unused_fallback_key_types in the sync response), so it + // may not have landed yet when the test first claims it. Retry until it + // appears rather than failing on the first (empty) claim. + var result gjson.Result + claimer.MustDo(t, "POST", []string{ "_matrix", "client", "v3", "keys", "claim", }, client.WithJSONBody(t, map[string]any{ "one_time_keys": map[string]any{ @@ -31,9 +41,18 @@ func mustClaimFallbackKey(t *testing.T, claimer *client.CSAPI, target *cc.User) target.DeviceID: "signed_curve25519", }, }, + }), client.WithRetryUntil(10*time.Second, func(res *http.Response) bool { + result = must.ParseJSON(t, res.Body) + res.Body.Close() + otks := result.Get(fmt.Sprintf( + "one_time_keys.%s.%s", client.GjsonEscape(target.UserID), client.GjsonEscape(target.DeviceID), + )) + if otks.Exists() { + return true + } + t.Logf("fallback key not yet uploaded for %s|%s, retrying: %v", target.UserID, target.DeviceID, result.Raw) + return false })) - defer res.Body.Close() - result := must.ParseJSON(t, res.Body) otks := result.Get(fmt.Sprintf( "one_time_keys.%s.%s", client.GjsonEscape(target.UserID), client.GjsonEscape(target.DeviceID), )) @@ -49,6 +68,7 @@ func mustClaimFallbackKey(t *testing.T, claimer *client.CSAPI, target *cc.User) return fallbackKeyID, fallbackKey } +// mustClaimOTKs repeatedly claims one-time keys for the target user until otkCount keys have been claimed. func mustClaimOTKs(t *testing.T, claimer *client.CSAPI, target *cc.User, otkCount int) { t.Helper() for i := 0; i < otkCount; i++ { @@ -157,6 +177,7 @@ func TestFallbackKeyIsUsedIfOneTimeKeysRunOut(t *testing.T) { }) } +// TestFailedOneTimeKeyUploadRetries tests that the client retries uploading one-time keys if the upload fails. func TestFailedOneTimeKeyUploadRetries(t *testing.T) { Instance().ForEachClientType(t, func(t *testing.T, clientType api.ClientType) { tc := Instance().CreateTestContext(t, clientType, clientType) @@ -205,6 +226,7 @@ func TestFailedOneTimeKeyUploadRetries(t *testing.T) { }) } +// TestFailedKeysClaimRetries tests that the client retries claiming one-time keys if the claim fails. func TestFailedKeysClaimRetries(t *testing.T) { Instance().ForEachClientType(t, func(t *testing.T, clientType api.ClientType) { tc := Instance().CreateTestContext(t, clientType, clientType)