agent build
This commit is contained in:
210
go-api/internal/tools/activity_test.go
Normal file
210
go-api/internal/tools/activity_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
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
|
||||
})()
|
||||
}
|
||||
Reference in New Issue
Block a user