package api_test import ( "context" "encoding/json" "fmt" "net/http" "testing" "github.com/owncord/server/db" "github.com/owncord/server/permissions" ) // ─── Authorization tests for channel read access (REST) ───────────────────── // These tests verify that permission checks (READ_MESSAGES) are enforced on // GET /api/v1/channels, GET /api/v1/channels/{id}/messages, and GET /api/v1/search. // denyReadMessages inserts a channel_override that denies READ_MESSAGES for the // given role on the given channel. func denyReadMessages(t *testing.T, database *db.DB, channelID, roleID int64) { t.Helper() _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, 0, ?)`, channelID, roleID, permissions.ReadMessages, ) if err != nil { t.Fatalf("denyReadMessages: %v", err) } } // ─── GET /api/v1/channels: permission filtering ───────────────────────────── func TestChannelList_FiltersOutDeniedChannels(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) // Create member user (roleID=4, has READ_MESSAGES by default). token := chTestCreateToken(t, database, "authz-member1", 4) chVisible, _ := database.CreateChannel(context.Background(), "visible", "text", "", "", 0) chHidden, _ := database.CreateChannel(context.Background(), "hidden", "text", "", "", 1) _ = chVisible // used implicitly in response // Deny READ_MESSAGES on the hidden channel for the Member role. denyReadMessages(t, database, chHidden, permissions.MemberRoleID) rr := chGet(t, router, "/api/v1/channels", token) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) } var channels []map[string]any if err := json.NewDecoder(rr.Body).Decode(&channels); err != nil { t.Fatalf("decode: %v", err) } if len(channels) != 1 { t.Errorf("expected 1 visible channel, got %d", len(channels)) } if len(channels) > 0 { name, _ := channels[0]["name"].(string) if name != "visible" { t.Errorf("visible channel name = %q, want %q", name, "visible") } } } func TestChannelList_AdminSeesAllChannels(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) // Owner (roleID=1) has Administrator bit — bypasses all checks. token := chTestCreateToken(t, database, "authz-owner1", 1) chA, _ := database.CreateChannel(context.Background(), "a", "text", "", "", 0) chB, _ := database.CreateChannel(context.Background(), "b", "text", "", "", 1) // Deny READ_MESSAGES on both channels for all roles. denyReadMessages(t, database, chA, permissions.MemberRoleID) denyReadMessages(t, database, chB, permissions.MemberRoleID) rr := chGet(t, router, "/api/v1/channels", token) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } var channels []any _ = json.NewDecoder(rr.Body).Decode(&channels) if len(channels) != 2 { t.Errorf("admin should see all 2 channels, got %d", len(channels)) } } // ─── GET /api/v1/channels/{id}/messages: permission check ─────────────────── func TestChannelMessages_DeniedByPermission(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "authz-member2", 4) chID, _ := database.CreateChannel(context.Background(), "restricted", "text", "", "", 0) // Deny READ_MESSAGES for Member role on this channel. denyReadMessages(t, database, chID, permissions.MemberRoleID) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) if rr.Code != http.StatusForbidden { t.Errorf("status = %d, want 403; body: %s", rr.Code, rr.Body.String()) } } func TestChannelMessages_AdminBypassesDeny(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "authz-owner2", 1) chID, _ := database.CreateChannel(context.Background(), "restricted", "text", "", "", 0) // Deny READ_MESSAGES for Member role — should not affect Owner. denyReadMessages(t, database, chID, permissions.MemberRoleID) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) if rr.Code != http.StatusOK { t.Errorf("status = %d, want 200; admin should bypass deny", rr.Code) } } // ─── GET /api/v1/search: permission filtering ─────────────────────────────── func TestSearch_FiltersResultsByPermission(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) // Create an owner to insert messages (owner can write anywhere). _ = chTestCreateToken(t, database, "authz-owner3", 1) owner, _ := database.GetUserByUsername(context.Background(), "authz-owner3") // Member user for search. memberToken := chTestCreateToken(t, database, "authz-member3", 4) chVisible, _ := database.CreateChannel(context.Background(), "pub", "text", "", "", 0) chHidden, _ := database.CreateChannel(context.Background(), "priv", "text", "", "", 1) // Insert messages in both channels with a common keyword. _, _ = database.CreateMessage(context.Background(), chVisible, owner.ID, "searchable keyword public", nil) _, _ = database.CreateMessage(context.Background(), chHidden, owner.ID, "searchable keyword private", nil) // Deny READ_MESSAGES on the hidden channel for members. denyReadMessages(t, database, chHidden, permissions.MemberRoleID) rr := chGet(t, router, "/api/v1/search?q=searchable", memberToken) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) } var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) results, ok := resp["results"].([]any) if !ok { t.Fatalf("results is not an array: %v", resp) } if len(results) != 1 { t.Errorf("expected 1 search result (public only), got %d", len(results)) } } func TestSearch_AdminSeesAllResults(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "authz-owner4", 1) owner, _ := database.GetUserByUsername(context.Background(), "authz-owner4") chA, _ := database.CreateChannel(context.Background(), "a", "text", "", "", 0) chB, _ := database.CreateChannel(context.Background(), "b", "text", "", "", 1) _, _ = database.CreateMessage(context.Background(), chA, owner.ID, "findme alpha", nil) _, _ = database.CreateMessage(context.Background(), chB, owner.ID, "findme beta", nil) // Deny READ_MESSAGES on both for member role — admin bypasses. denyReadMessages(t, database, chA, permissions.MemberRoleID) denyReadMessages(t, database, chB, permissions.MemberRoleID) rr := chGet(t, router, "/api/v1/search?q=findme", token) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) results := resp["results"].([]any) if len(results) != 2 { t.Errorf("admin should see all 2 results, got %d", len(results)) } } // ─── DM exclusion from channel list (BUG-093) ───────────────────────────── func TestChannelList_ExcludesDMChannels_Member(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "dm-excl-member", 4) // Create a normal text channel and a DM channel. database.CreateChannel(context.Background(), "general", "text", "", "", 0) database.ExecContext(context.Background(), `INSERT INTO channels (name, type, position) VALUES ('dm-1', 'dm', 0)`) rr := chGet(t, router, "/api/v1/channels", token) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) } var channels []map[string]any if err := json.NewDecoder(rr.Body).Decode(&channels); err != nil { t.Fatalf("decode: %v", err) } for _, ch := range channels { if ch["type"] == "dm" { t.Errorf("DM channel should not appear in channel list, got: %v", ch["name"]) } } if len(channels) != 1 { t.Errorf("expected 1 channel (text only), got %d", len(channels)) } } func TestChannelList_ExcludesDMChannels_Admin(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "dm-excl-admin", 1) // Owner database.CreateChannel(context.Background(), "general", "text", "", "", 0) database.CreateChannel(context.Background(), "voice", "voice", "", "", 1) database.ExecContext(context.Background(), `INSERT INTO channels (name, type, position) VALUES ('dm-1', 'dm', 0)`) rr := chGet(t, router, "/api/v1/channels", token) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } var channels []map[string]any _ = json.NewDecoder(rr.Body).Decode(&channels) for _, ch := range channels { if ch["type"] == "dm" { t.Errorf("DM channel should not appear even for admin, got: %v", ch["name"]) } } if len(channels) != 2 { t.Errorf("expected 2 channels (text+voice), got %d", len(channels)) } }