mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(voice): 1 defect(s) (OC-0277) * fix(voice): 1 defect(s) (OC-0278) * fix(client): 1 defect(s) (OC-0280) refreshDmSidebar() rebuilds the entire DM sidebar subtree on every dmStore.channels change - which includes presence flips and new messages, not just DM list changes. The "Find a conversation" filter text and input focus live only in that destroyed subtree, so they were silently wiped mid-typing. Capture and restore both across the destroy+recreate cycle. * fix(ws): 1 defect(s) (OC-0285) * fix(client): 1 defect(s) (OC-0286) * fix(client): 1 defect(s) (OC-0288) Consume the legacy unscoped mute key after migrating it onto the first host, so a brand-new host with no scoped key of its own no longer reads through to the same legacy list and inherits another server's mutes. * fix(voice): 1 defect(s) (OC-0290) * fix(db): 1 defect(s) (OC-0293) DecrementMentionCounts reversed mention_count bumps that were never applied: message_mentions stores every resolved mention id including the author's blockers, while applyMentionCounts excludes blockers before incrementing. Deleting a blocked author's message therefore wiped an unrelated, genuine mention badge on the same read_states row. Mirror the block exclusion in the decrement UPDATE. * fix(db): 1 defect(s) (OC-0294) DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards. * fix(client): 1 defect(s) (OC-0295) MemberList rebuilt every row on any non-presence-only membersStore change and on every roles_update, but registered each row's click/contextmenu listeners on the component-lifetime disposable.signal, which only aborts at destroy(). Discarded rows therefore stayed reachable (and their listeners live) for the component's whole lifetime. Route per-row listeners through a per-render AbortController that is aborted and replaced at the top of every render, and aborted again in destroy(). * fix(identity): 1 defect(s) (OC-0297) UpdateProfile's post-commit re-read of the user row could fail for reasons unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion) and was reported as ErrInternal even though UpdateUserProfile had already committed. Callers that treat any UpdateProfile error as proof the write never landed — handleUploadAvatar deletes the file it just stored — would delete a file the committed avatar column now points at, permanently breaking the avatar with no user_update broadcast. Since UpdateUserProfile only writes username/avatar/display_name/about, merge those four onto the pre-write snapshot to reconstruct the committed row without needing the re-read to succeed, and log the read failure. * fix(ws): 2 defect(s) (OC-0298, OC-0299) - OC-0298: applyConnectStatus stamped c.user.Status even when the UpdateUserStatus write failed, so auth_ok and the presence broadcast claimed a status users.status disagreed with, and buildReady's ListMembers read never self-corrected for the session. - OC-0299: refreshUserSnapshot silently fell back to roleName "member" when the new role lookup failed, pinning the session to a fabricated role on the wire. It now fails closed like the sibling lookups in upgradeAndAuth and handleFreshConnect. * fix(client): 1 defect(s) (OC-0300) * fix(client): 1 defect(s) (OC-0301) * fix(ws): 1 defect(s) (OC-0302) * fix(api): 1 defect(s) (OC-0305) handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route. * fix(client): 2 defect(s) (OC-0306, OC-0308) * fix(client): 1 defect(s) (OC-0307) QuickSwitcher registered a per-row click listener against the overlay-lifetime AbortSignal, but renderResults() rebuilds every row on each keystroke, arrow key, and store refresh. Discarded rows kept their listeners alive until the overlay closed. Replaced with one delegated click listener on the stable results container, keyed off the data-channelid each row already carries. * fix(client): 1 defect(s) (OC-0310) * fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292) Reap a soft-deleted message's attachment files, count lapsed temporary bans as active users in the require_2fa enrollment gate, and only apply the 2FA-enrollment precondition when require_2fa itself is being enabled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * test(api): sync apiTestSchema with the user_blocks migration DeleteAccount's mention-count reversal joins user_blocks; the api package's hand-rolled schema fixture predates migration 012. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296) Decouple the E2EE identity-mismatch modal and right-click popovers from the sidebar's per-render abort signal, and let global drag listeners survive a mid-drag re-render. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(voice): 2 defect(s) (OC-0283, OC-0287) Retire a departed peer's E2EE key unconditionally on leave, and surface a failed microphone unmute instead of reporting an unmuted state the room never saw. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309) Guard the DM call button against redialing the channel already joined, resolve the incoming-call banner's caller through the nickname-aware display name, and keep the DM profile sidebar subscribed to live member/status updates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * style(client): prettier-format the dm-store test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(server): 1 defect(s) (OC-0284) Make message soft-delete a compare-and-set so a repeated chat_delete cannot reverse mention counts twice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(server): 2 defect(s) (OC-0276, OC-0304) Re-sync a resumed connection's voice E2EE peer keys in registerNow (announce frames are unsequenced and cannot be replayed), and apply the live-connection presence rule to every DM payload DMService builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * chore(ledger): record the 2026-08-21 hunt findings as fixed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * chore(ledger): independent revert-proof pass for OC-0276..OC-0310 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * refactor(service): extract DeleteMessage authorization into a helper Keeps DeleteMessage under the cyclop complexity ceiling after the OC-0284 guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo --------- Co-authored-by: Claude <noreply@anthropic.com>
225 lines
7.0 KiB
Go
225 lines
7.0 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/owncord/server/api"
|
|
"github.com/owncord/server/auth"
|
|
"github.com/owncord/server/config"
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
)
|
|
|
|
// setupDiagnosticsRouter creates a full router with an authenticated user for
|
|
// diagnostics testing.
|
|
func setupDiagnosticsRouter(t *testing.T) (http.Handler, string, *db.DB) {
|
|
t.Helper()
|
|
|
|
database, err := db.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("db.Open: %v", err)
|
|
}
|
|
if err := db.Migrate(database); err != nil {
|
|
t.Fatalf("db.Migrate: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
|
|
cfg := &config.Config{
|
|
Server: config.ServerConfig{
|
|
Name: "Test Server",
|
|
Port: 8443,
|
|
},
|
|
}
|
|
|
|
handler, _, cleanup := api.NewRouter(cfg, database, "1.0.0-test", nil, nil)
|
|
t.Cleanup(cleanup)
|
|
|
|
// Create a user and session for authenticated requests.
|
|
uid, _ := database.CreateUser(context.Background(), "diaguser", "$2a$12$fake", 1)
|
|
token := "diagtest-token-123"
|
|
hash := auth.HashToken(token)
|
|
_, _ = database.ExecContext(context.Background(),
|
|
`INSERT INTO sessions (user_id, token, device, ip_address, expires_at)
|
|
VALUES (?, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z')`,
|
|
uid, hash,
|
|
)
|
|
|
|
return handler, token, database
|
|
}
|
|
|
|
func TestDiagnosticsConnectivity_ReturnsData(t *testing.T) {
|
|
router, token, _ := setupDiagnosticsRouter(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/diagnostics/connectivity", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
|
|
// Verify top-level sections exist.
|
|
for _, section := range []string{"server", "voice", "client"} {
|
|
if _, ok := resp[section]; !ok {
|
|
t.Errorf("missing section %q in diagnostics response", section)
|
|
}
|
|
}
|
|
|
|
// Verify server section has expected fields.
|
|
server, _ := resp["server"].(map[string]any)
|
|
if server["version"] != "1.0.0-test" {
|
|
t.Errorf("server.version = %v, want 1.0.0-test", server["version"])
|
|
}
|
|
}
|
|
|
|
// TestDiagnosticsConnectivity_HonoursTrustedProxies reproduces OC-0305: behind
|
|
// a configured trusted reverse proxy, the diagnostics endpoint must report the
|
|
// real client address from X-Forwarded-For, not the proxy's own RemoteAddr —
|
|
// matching the same route's RateLimitMiddleware, which already honours
|
|
// cfg.Server.TrustedProxies.
|
|
func TestDiagnosticsConnectivity_HonoursTrustedProxies(t *testing.T) {
|
|
database, err := db.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("db.Open: %v", err)
|
|
}
|
|
if err := db.Migrate(database); err != nil {
|
|
t.Fatalf("db.Migrate: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
|
|
cfg := &config.Config{
|
|
Server: config.ServerConfig{
|
|
Name: "Test Server",
|
|
Port: 8443,
|
|
TrustedProxies: []string{"127.0.0.1/32"},
|
|
},
|
|
}
|
|
|
|
handler, _, cleanup := api.NewRouter(cfg, database, "1.0.0-test", nil, nil)
|
|
t.Cleanup(cleanup)
|
|
|
|
uid, _ := database.CreateUser(context.Background(), "diagproxyuser", "$2a$12$fake", 1)
|
|
token := "diagtest-proxy-token"
|
|
hash := auth.HashToken(token)
|
|
if _, err := database.ExecContext(context.Background(),
|
|
`INSERT INTO sessions (user_id, token, device, ip_address, expires_at)
|
|
VALUES (?, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z')`,
|
|
uid, hash,
|
|
); err != nil {
|
|
t.Fatalf("insert session: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/diagnostics/connectivity", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("X-Forwarded-For", "203.0.113.9")
|
|
req.RemoteAddr = "127.0.0.1:9999" // the trusted reverse proxy's own hop
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
|
|
client, _ := resp["client"].(map[string]any)
|
|
if client["remote_addr"] != "203.0.113.9" {
|
|
t.Errorf("client.remote_addr = %v, want 203.0.113.9 (the real client behind the trusted proxy)", client["remote_addr"])
|
|
}
|
|
if isPrivate, _ := client["is_private_network"].(bool); isPrivate {
|
|
t.Errorf("client.is_private_network = true, want false for public client 203.0.113.9")
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticsConnectivity_Unauthenticated(t *testing.T) {
|
|
router, _, _ := setupDiagnosticsRouter(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/diagnostics/connectivity", nil)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
// TestDiagnosticsConnectivity_MemberForbidden locks the RequirePermission gate
|
|
// on the route. Without it, only 200-for-owner and 401-unauthenticated were
|
|
// covered, so deleting the ADMINISTRATOR gate broke no test while exposing the
|
|
// server's network topology to every member.
|
|
func TestDiagnosticsConnectivity_MemberForbidden(t *testing.T) {
|
|
router, _, database := setupDiagnosticsRouter(t)
|
|
|
|
uid, err := database.CreateUser(context.Background(), "diagmember", "$2a$12$fake", int(permissions.MemberRoleID))
|
|
if err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
token := "diagtest-member-token"
|
|
if _, err := database.ExecContext(context.Background(),
|
|
`INSERT INTO sessions (user_id, token, device, ip_address, expires_at)
|
|
VALUES (?, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z')`,
|
|
uid, auth.HashToken(token),
|
|
); err != nil {
|
|
t.Fatalf("insert session: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/diagnostics/connectivity", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── isPrivateIP tests ──────────────────────────────────────────────────────
|
|
|
|
func TestIsPrivateIP(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ip string
|
|
want bool
|
|
}{
|
|
{"10.x.x.x", "10.0.0.1", true},
|
|
{"172.16.x.x", "172.16.0.1", true},
|
|
{"172.17.x.x", "172.17.5.5", true},
|
|
{"172.31.x.x", "172.31.255.255", true},
|
|
{"192.168.x.x", "192.168.1.1", true},
|
|
{"127.x.x.x", "127.0.0.1", true},
|
|
{"::1 loopback", "::1", true},
|
|
{"fc ULA", "fc00::1", true},
|
|
{"fd ULA", "fd12::1", true},
|
|
{"public 8.8.8.8", "8.8.8.8", false},
|
|
{"public 203.x", "203.0.113.1", false},
|
|
{"public 1.1.1.1", "1.1.1.1", false},
|
|
{"172.32 not private", "172.32.0.1", false},
|
|
{"empty string", "", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := api.IsPrivateIPForTest(tt.ip)
|
|
if got != tt.want {
|
|
t.Errorf("isPrivateIP(%q) = %v, want %v", tt.ip, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|