package tools_test import ( "context" "encoding/json" "testing" "github.com/krow/krow-backend/go-api/internal/authctx" "github.com/krow/krow-backend/go-api/internal/testutil" "github.com/krow/krow-backend/go-api/internal/tools" ) // freshOrg creates an organization with no history. // // The harness seeds a demo tenant that already has activity in it, so these // tests build their own: an assertion of "exactly 10 events" is only a real // assertion when the fixture controls every row. func freshOrg(t *testing.T, h *testutil.Harness, slug string) string { t.Helper() var id string if err := h.Pool.QueryRow(context.Background(), `INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id::text`, slug, slug).Scan(&id); err != nil { t.Fatalf("create org %s: %v", slug, err) } return id } // seedActivity writes audit rows for two accounts in one org, and one in // another, so a leak across either boundary is detectable. func seedActivity(t *testing.T, h *testutil.Harness, mineOrg, otherOrg string) (admin, talent authctx.Identity) { t.Helper() ctx := context.Background() rows := []struct { org, eventType, email string n int }{ {mineOrg, "login", "boss@example.test", 5}, {mineOrg, "hire_candidate", "boss@example.test", 3}, {mineOrg, "login", "worker@example.test", 2}, // A different tenant entirely. Must never appear in either caller's // counts, shares or account total. {otherOrg, "login", "outsider@example.test", 40}, {otherOrg, "delete_position", "outsider@example.test", 40}, } for _, r := range rows { for i := 0; i < r.n; i++ { if _, err := h.Pool.Exec(ctx, `INSERT INTO user_activity (org_id, event_type, user_email, user_name) VALUES ($1::uuid, $2, $3, 'Someone')`, r.org, r.eventType, r.email); err != nil { t.Fatalf("seed activity: %v", err) } } } return authctx.Identity{UserID: "u1", OrgID: mineOrg, Role: "admin", Email: "boss@example.test"}, authctx.Identity{UserID: "u2", OrgID: mineOrg, Role: "talent", Email: "worker@example.test"} } // seeded builds two fresh tenants and fills them. Returns the two callers. func seeded(t *testing.T, h *testutil.Harness) (admin, talent authctx.Identity) { t.Helper() mine := freshOrg(t, h, "mine-co") other := freshOrg(t, h, "other-co") return seedActivity(t, h, mine, other) } func runBreakdown(t *testing.T, h *testutil.Harness, ident authctx.Identity, args string) tools.Result { t.Helper() reg := tools.NewRegistry() reg.MustRegister(tools.ActivityBreakdown(h.Pool)) return reg.Dispatch(context.Background(), tools.Context{Principal: ident, RunID: "run_test"}, "activity_breakdown", json.RawMessage(args)) } func data(t *testing.T, res tools.Result) map[string]any { t.Helper() if res.Error != nil { t.Fatalf("unexpected tool error: %s — %s", res.Error.Code, res.Error.Message) } encoded, _ := json.Marshal(res.Data) var m map[string]any if err := json.Unmarshal(encoded, &m); err != nil { t.Fatalf("result was not an object: %v", err) } return m } func TestActivityBreakdownScopesToTheTenant(t *testing.T) { h := testutil.New(t) admin, _ := seeded(t, h) got := data(t, runBreakdown(t, h, admin, `{}`)) // 10 in this org. The other tenant's 80 must not be counted, and must not // show up in the share arithmetic either. if n := got["totalEvents"].(float64); n != 10 { t.Errorf("totalEvents = %v, want 10 — the other tenant's rows leaked", n) } if n := got["activeAccounts"].(float64); n != 2 { t.Errorf("activeAccounts = %v, want 2", n) } // `delete_position` exists only in the other org. encoded, _ := json.Marshal(got["kinds"]) if string(encoded) != "" && contains(string(encoded), "delete position") { t.Errorf("an event kind from another tenant appeared: %s", encoded) } } func TestActivityBreakdownScopesTalentToTheirOwnRows(t *testing.T) { // I1: the agent may read exactly what the caller could read directly. The // policy scopes a talent caller to their own email, and this asserts the // scope is applied as a pre-filter — the totals and shares are computed // over their rows alone. h := testutil.New(t) _, talent := seeded(t, h) got := data(t, runBreakdown(t, h, talent, `{}`)) if n := got["totalEvents"].(float64); n != 2 { t.Errorf("totalEvents = %v, want 2 — a talent caller must see only their own entries", n) } // The leak that post-filtering would produce: the row text is hidden but // the organization's volume shows through the account count. if n := got["activeAccounts"].(float64); n != 1 { t.Errorf("activeAccounts = %v, want 1 — the tenant's account count leaked through the aggregate", n) } encoded, _ := json.Marshal(got["kinds"]) if contains(string(encoded), "hire candidate") { t.Errorf("a talent caller saw an event kind they did not perform: %s", encoded) } } func TestActivityBreakdownDeniesWithoutATenant(t *testing.T) { h := testutil.New(t) res := runBreakdown(t, h, authctx.Identity{UserID: "u", Role: "admin", Email: "x@example.test"}, `{}`) if res.Error == nil || res.Error.Code != tools.CodeDenied { t.Fatalf("want a denial, got %+v", res) } // §8: the denial must not reveal whether anything exists. if contains(res.Error.Message, "activity") || contains(res.Error.Message, "org") { t.Errorf("the denial described what was refused: %q", res.Error.Message) } } func TestActivityBreakdownDeniesAnUnknownRole(t *testing.T) { // Deny by default: a role the policy table does not list permits nothing. h := testutil.New(t) res := runBreakdown(t, h, authctx.Identity{UserID: "u", OrgID: h.OrgID, Role: "superuser", Email: "x@example.test"}, `{}`) if res.Error == nil || res.Error.Code != tools.CodeDenied { t.Fatalf("an unlisted role must be denied, got %+v", res) } } func TestActivityBreakdownRejectsAnUnknownPeriod(t *testing.T) { h := testutil.New(t) admin, _ := seeded(t, h) res := runBreakdown(t, h, admin, `{"period":"since-tuesday"}`) if res.Error == nil || res.Error.Code != tools.CodeInvalidInput { t.Fatalf("want invalid input for an unknown period, got %+v", res) } } func TestActivityBreakdownCountsKindsBeforeLimiting(t *testing.T) { // "How many kinds are there" must stay true even when the list is shorter, // and the shortening must be declared rather than silent. h := testutil.New(t) admin, _ := seeded(t, h) got := data(t, runBreakdown(t, h, admin, `{"limit":1}`)) if n := got["distinctKinds"].(float64); n != 2 { t.Errorf("distinctKinds = %v, want 2 — counted before the limit", n) } if n, ok := got["omittedKinds"].(float64); !ok || n != 1 { t.Errorf("omittedKinds = %v, want 1 — a shortened list must say so", got["omittedKinds"]) } } func TestActivityBreakdownReportsEmptyAsAnAnswer(t *testing.T) { h := testutil.New(t) empty := freshOrg(t, h, "empty-co") admin := authctx.Identity{UserID: "u", OrgID: empty, Role: "admin", Email: "boss@example.test"} got := data(t, runBreakdown(t, h, admin, `{}`)) if n := got["totalEvents"].(float64); n != 0 { t.Fatalf("totalEvents = %v, want 0 on an empty log", n) } if _, ok := got["note"]; !ok { t.Error("an empty result must say it is a real answer, not a failure to look") } } func contains(haystack, needle string) bool { return len(haystack) >= len(needle) && (func() bool { for i := 0; i+len(needle) <= len(haystack); i++ { if haystack[i:i+len(needle)] == needle { return true } } return false })() }