package service import ( "errors" "net/url" "testing" "github.com/krow/krow-backend/go-api/internal/authctx" "github.com/krow/krow-backend/go-api/internal/domain" ) // The two callers validation distinguishes. Only the role is read. var ( asOperator = authctx.Identity{Role: string(domain.RoleAdmin)} asTalent = authctx.Identity{Role: string(domain.RoleTalent)} ) // These exercise query parsing and validation without a database, so the // contract's defaults are pinned even when PostgreSQL is not available. func resource(t *testing.T, path string) *domain.Resource { t.Helper() res, ok := domain.ResourceByPath[path] if !ok { t.Fatalf("no resource for path %q", path) } return res } func parse(t *testing.T, path, query string) (domain.ListParams, error) { t.Helper() values, err := url.ParseQuery(query) if err != nil { t.Fatalf("bad test query %q: %v", query, err) } return New(resource(t, path), nil).ParseList(values) } // Each default is the literal argument at the frontend call site // (api-contract.md ยง8.1), not a generic value. func TestParseListDefaults(t *testing.T) { for _, tc := range []struct { path string limit int sort string desc bool }{ {"job-postings", 100, "created_date", true}, {"job-applications", 200, "ai_score", true}, {"worker-profiles", 500, "krow_score", true}, {"shift-records", 500, "created_date", true}, {"user-activity", 500, "created_date", true}, {"courses", 200, "created_date", true}, } { p, err := parse(t, tc.path, "") if err != nil { t.Fatalf("%s: %v", tc.path, err) } if p.Limit != tc.limit { t.Errorf("%s limit = %d, want %d", tc.path, p.Limit, tc.limit) } if p.Sort != tc.sort || p.Desc != tc.desc { t.Errorf("%s sort = %q desc=%v, want %q desc=%v", tc.path, p.Sort, p.Desc, tc.sort, tc.desc) } } } // An explicitly empty ?sort= means no ordering, matching `if (!sort) return records`. func TestParseListEmptySortMeansUnordered(t *testing.T) { p, err := parse(t, "job-postings", "sort=") if err != nil { t.Fatal(err) } if p.Sort != "" { t.Errorf("sort = %q, want empty", p.Sort) } } func TestParseListLimitClampAndRejection(t *testing.T) { p, err := parse(t, "job-postings", "limit=99999") if err != nil { t.Fatal(err) } if p.Limit != MaxLimit { t.Errorf("limit = %d, want it clamped to %d", p.Limit, MaxLimit) } // A zero limit is legitimate: store.js's slice(0, 0) returns nothing. if p, err := parse(t, "job-postings", "limit=0"); err != nil || p.Limit != 0 { t.Errorf("limit=0 -> %d, %v", p.Limit, err) } for _, bad := range []string{"limit=-1", "limit=abc", "offset=-3", "offset=x"} { if _, err := parse(t, "job-postings", bad); err == nil { t.Errorf("%s was accepted", bad) } } } func TestParseListRejectsUnknownSortAndFilter(t *testing.T) { if _, err := parse(t, "job-postings", "sort=-nope"); err == nil { t.Error("unknown sort field was accepted") } if _, err := parse(t, "job-postings", "nope=1"); err == nil { t.Error("unknown filter field was accepted") } // Arrays and JSON are not comparable for equality, so they are not filterable. if _, err := parse(t, "job-postings", "responsibilities=x"); err == nil { t.Error("an array column was accepted as a filter") } if _, err := parse(t, "job-postings", "vetting_criteria=x"); err == nil { t.Error("a jsonb column was accepted as a filter") } } // A repeated parameter is membership, matching `Array.isArray(want)`. func TestParseListRepeatedParameterIsMembership(t *testing.T) { p, err := parse(t, "job-applications", "status=hired&status=interview") if err != nil { t.Fatal(err) } if len(p.Filters) != 1 { t.Fatalf("filters = %d, want 1", len(p.Filters)) } if len(p.Filters[0].Values) != 2 { t.Errorf("values = %v, want two", p.Filters[0].Values) } } // sort, limit and offset are reserved; no column collides with them. func TestReservedParametersAreNotFilters(t *testing.T) { p, err := parse(t, "job-applications", "sort=-ai_score&limit=5&offset=2&status=hired") if err != nil { t.Fatal(err) } if len(p.Filters) != 1 || p.Filters[0].Column.Name != "status" { t.Errorf("filters = %#v, want only status", p.Filters) } if p.Limit != 5 || p.Offset != 2 { t.Errorf("limit/offset = %d/%d, want 5/2", p.Limit, p.Offset) } for _, r := range []string{"sort", "limit", "offset"} { if _, isColumn := resource(t, "job-applications").Column(r); isColumn { t.Errorf("a column named %q collides with a reserved parameter", r) } } } func TestValidateRequiredAndUnknownAndEnum(t *testing.T) { svc := New(resource(t, "job-postings"), nil) if _, err := svc.validate(asOperator, domain.Record{}, true); err == nil { t.Error("a create with no title was accepted") } if _, err := svc.validate(asOperator, domain.Record{"title": " "}, true); err == nil { t.Error("a blank title was accepted") } if _, err := svc.validate(asOperator, domain.Record{"title": "X", "bogus": 1}, true); err == nil { t.Error("an unknown field was accepted") } if _, err := svc.validate(asOperator, domain.Record{"title": "X", "status": "archived"}, true); err == nil { t.Error("an invalid enum value was accepted") } if _, err := svc.validate(asOperator, domain.Record{"title": "X", "status": "active"}, true); err != nil { t.Errorf("a valid payload was rejected: %v", err) } // Server-owned fields are stripped, not rejected. out, err := svc.validate(asOperator, domain.Record{"title": "X", "id": "abc", "org_id": "def"}, true) if err != nil { t.Fatalf("server-owned fields caused a rejection: %v", err) } if _, present := out["id"]; present { t.Error("id survived validation") } if _, present := out["org_id"]; present { t.Error("org_id survived validation") } // An update needs no required fields โ€” it is a partial by definition. if _, err := svc.validate(asOperator, domain.Record{"location": "Here"}, false); err != nil { t.Errorf("a partial update was rejected: %v", err) } } // A talent-only derivation is only server-supplied for a talent caller. // // The column records who the row is ABOUT, and the repository fills it from the // session for talent and for nobody else (repo.derivedValues). Validation has // to agree: an operator filing an application for somebody else must be told // `email` is required, rather than being let through to a not-null violation // from SQL โ€” and a talent caller must not be asked for the value the server is // about to override anyway. func TestValidateHonoursTalentOnlyDerivation(t *testing.T) { apps := New(resource(t, "job-applications"), nil) body := domain.Record{ "job_posting_id": "00000000-0000-0000-0000-000000000000", "applicant_name": "Someone", } if _, err := apps.validate(asTalent, body, true); err != nil { t.Errorf("a talent create without email was rejected: %v", err) } _, err := apps.validate(asOperator, body, true) if err == nil { t.Fatal("an operator create without email was accepted") } var apiErr *domain.Error if !errors.As(err, &apiErr) { t.Fatalf("error is not an API error: %v", err) } if apiErr.Details["email"] != "required" { t.Errorf("details = %v, want email: required", apiErr.Details) } // evidence.worker_email is the same shape, and the case the original // comment in serverSupplies was written for. ev := New(resource(t, "evidence"), nil) if _, err := ev.validate(asTalent, domain.Record{"type": "photo_identify"}, true); err != nil { t.Errorf("a talent evidence create without worker_email was rejected: %v", err) } if _, err := ev.validate(asOperator, domain.Record{"type": "photo_identify"}, true); err == nil { t.Error("an operator evidence create without worker_email was accepted") } // A derivation that is NOT talent-only stays server-supplied for everyone: // user_activity records who acted, whoever that is. act := New(resource(t, "user-activity"), nil) for name, ident := range map[string]authctx.Identity{"operator": asOperator, "talent": asTalent} { if _, err := act.validate(ident, domain.Record{"event_type": "x"}, true); err != nil { t.Errorf("%s: an activity create was rejected: %v", name, err) } } } func TestIsUUID(t *testing.T) { valid := []string{ "00000000-0000-0000-0000-000000000000", "9A88DEBC-76E5-572C-A7E7-6EB5F43A6705", } for _, v := range valid { if !isUUID(v) { t.Errorf("%q rejected", v) } } invalid := []string{"", "not-a-uuid", "00000000000000000000000000000000", "00000000-0000-0000-0000-00000000000g", "00000000-0000-0000-0000-0000000000000"} for _, v := range invalid { if isUUID(v) { t.Errorf("%q accepted", v) } } } // Every resource must declare a sort column that actually exists, and a limit // in range โ€” a typo in the generated metadata would otherwise only surface as a // 400 at runtime. func TestEveryResourceIsCoherent(t *testing.T) { for _, res := range domain.AllResources { field := res.DefaultSort if len(field) > 0 && field[0] == '-' { field = field[1:] } if !res.Sortable(field) { t.Errorf("%s: default sort %q is not a column", res.Name, res.DefaultSort) } if res.DefaultLimit < 1 || res.DefaultLimit > MaxLimit { t.Errorf("%s: default limit %d is out of range", res.Name, res.DefaultLimit) } if _, ok := res.Column("id"); !ok { t.Errorf("%s: no id column, so the sort tiebreaker cannot apply", res.Name) } if _, ok := res.Column("org_id"); !ok { t.Errorf("%s: no org_id column, so it cannot be scoped", res.Name) } } }