430 lines
15 KiB
Go
430 lines
15 KiB
Go
package httpserver_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"testing"
|
|
)
|
|
|
|
// GET /api/v1/owliver/suggestions.
|
|
//
|
|
// The ranking itself is tested in internal/owliver, against no database and no
|
|
// server. What is tested here is only what the HTTP boundary adds: the session
|
|
// requirement, the query-string contract, the response envelope, and the fact
|
|
// that the role deciding which readings exist is the session's rather than
|
|
// anything the caller can set.
|
|
|
|
const suggestPath = "/api/v1/owliver/suggestions"
|
|
|
|
// suggestURL builds the endpoint's address, escaping as a browser would.
|
|
func suggestURL(page, query string) string {
|
|
v := url.Values{}
|
|
if page != "" {
|
|
v.Set("page", page)
|
|
}
|
|
if query != "" {
|
|
v.Set("query", query)
|
|
}
|
|
return suggestPath + "?" + v.Encode()
|
|
}
|
|
|
|
// suggestions reads the list out of the data envelope, failing the test if the
|
|
// response is not shaped as the contract says.
|
|
func suggestions(t *testing.T, r response) []map[string]any {
|
|
t.Helper()
|
|
if r.code != http.StatusOK {
|
|
t.Fatalf("status %d, body %v", r.code, r.body)
|
|
}
|
|
data, ok := r.body["data"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("data is not an object: %v", r.body)
|
|
}
|
|
raw, ok := data["suggestions"].([]any)
|
|
if !ok {
|
|
// json null decodes to nil, and an absent key to nothing at all. Both
|
|
// break a client that iterates the list without checking.
|
|
t.Fatalf("suggestions is not an array (got %#v)", data["suggestions"])
|
|
}
|
|
out := make([]map[string]any, len(raw))
|
|
for i, item := range raw {
|
|
entry, ok := item.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("suggestion %d is not an object: %#v", i, item)
|
|
}
|
|
out[i] = entry
|
|
}
|
|
return out
|
|
}
|
|
|
|
/* ── Authentication ─────────────────────────────────────────────────────── */
|
|
|
|
// The endpoint is not on the public allowlist. Which readings exist depends on
|
|
// who is asking, so an anonymous suggestion has no meaning.
|
|
func TestOwliverSuggestionsRequireASession(t *testing.T) {
|
|
a := newAPI(t)
|
|
|
|
got := a.doAnon("GET", suggestURL("positions", "pipeline"), nil)
|
|
if got.code != http.StatusUnauthorized {
|
|
t.Fatalf("status %d, want 401", got.code)
|
|
}
|
|
if code := got.codeOrEmpty(); code != "unauthorized" {
|
|
t.Fatalf("error code %q, want unauthorized", code)
|
|
}
|
|
// A refusal must not describe the catalogue it refused to rank.
|
|
if _, present := got.body["data"]; present {
|
|
t.Fatalf("an unauthenticated refusal carried data: %v", got.body)
|
|
}
|
|
}
|
|
|
|
/* ── The query string ───────────────────────────────────────────────────── */
|
|
|
|
func TestOwliverSuggestionsValidation(t *testing.T) {
|
|
a := newAPI(t)
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
want int
|
|
}{
|
|
{"no page", suggestPath, http.StatusBadRequest},
|
|
{"blank page", suggestPath + "?page=%20", http.StatusBadRequest},
|
|
{"unknown page", suggestURL("nowhere", "pipeline"), http.StatusBadRequest},
|
|
{"a route, not a surface", suggestURL("/admin/positions", "pipeline"), http.StatusBadRequest},
|
|
{"unknown parameter", suggestURL("positions", "pipeline") + "&role=admin", http.StatusBadRequest},
|
|
|
|
// A query is optional: with nothing typed there is nothing to rank, and
|
|
// that is an empty list rather than a refusal.
|
|
{"no query", suggestURL("positions", ""), http.StatusOK},
|
|
{"page alias", suggestURL("hired", "recent"), http.StatusOK},
|
|
{"page spelled loosely", suggestURL("Talent Pool", "availability"), http.StatusOK},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := a.do("GET", c.path, nil)
|
|
if got.code != c.want {
|
|
t.Fatalf("status %d, want %d (body %v)", got.code, c.want, got.body)
|
|
}
|
|
if c.want == http.StatusBadRequest && got.codeOrEmpty() != "invalid_query" {
|
|
t.Fatalf("error code %q, want invalid_query", got.codeOrEmpty())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The mux answers anything but GET, so the endpoint cannot be reached with a
|
|
// body that might carry a page, a role or an identity.
|
|
func TestOwliverSuggestionsAreReadOnly(t *testing.T) {
|
|
a := newAPI(t)
|
|
for _, method := range []string{"POST", "PATCH", "DELETE", "PUT"} {
|
|
got := a.do(method, suggestURL("positions", "pipeline"), map[string]any{"page": "positions"})
|
|
if got.code != http.StatusMethodNotAllowed {
|
|
t.Errorf("%s: status %d, want 405", method, got.code)
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── The response ───────────────────────────────────────────────────────── */
|
|
|
|
func TestOwliverSuggestionsResponseShape(t *testing.T) {
|
|
a := newAPI(t) // the seeded user is an admin
|
|
|
|
got := suggestions(t, a.do("GET", suggestURL("positions", "pipeline"), nil))
|
|
if len(got) == 0 {
|
|
t.Fatal("pipeline on positions returned nothing")
|
|
}
|
|
if len(got) > 3 {
|
|
t.Fatalf("%d suggestions, the cap is 3", len(got))
|
|
}
|
|
|
|
seenIntent, seenText := map[string]bool{}, map[string]bool{}
|
|
for i, s := range got {
|
|
text, _ := s["text"].(string)
|
|
intent, _ := s["intent"].(string)
|
|
if text == "" || intent == "" {
|
|
t.Fatalf("suggestion %d is incomplete: %v", i, s)
|
|
}
|
|
if seenIntent[intent] {
|
|
t.Fatalf("duplicate intent %q", intent)
|
|
}
|
|
if seenText[text] {
|
|
t.Fatalf("duplicate text %q", text)
|
|
}
|
|
seenIntent[intent], seenText[text] = true, true
|
|
|
|
// Nothing internal may ride along: no terms, no resource names, no
|
|
// scores, no page keys.
|
|
for key := range s {
|
|
switch key {
|
|
case "text", "intent", "capability":
|
|
default:
|
|
t.Fatalf("suggestion %d exposes %q: %v", i, key, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Asking for a rendering names it in the answer — and only where the reading
|
|
// can actually be drawn that way.
|
|
func TestOwliverSuggestionsCarryARequestedShape(t *testing.T) {
|
|
a := newAPI(t)
|
|
|
|
got := suggestions(t, a.do("GET", suggestURL("positions", "show hiring activity as a flow"), nil))
|
|
if len(got) != 1 {
|
|
t.Fatalf("got %d suggestions, want 1: %v", len(got), got)
|
|
}
|
|
if got[0]["intent"] != "hiring-operations" || got[0]["capability"] != "flow" {
|
|
t.Fatalf("got %v", got[0])
|
|
}
|
|
|
|
// With no shape asked for, the field is absent rather than empty.
|
|
plain := suggestions(t, a.do("GET", suggestURL("positions", "draft"), nil))
|
|
if len(plain) == 0 {
|
|
t.Fatal("draft on positions returned nothing")
|
|
}
|
|
if _, present := plain[0]["capability"]; present {
|
|
t.Fatalf("capability was sent for an unshaped query: %v", plain[0])
|
|
}
|
|
}
|
|
|
|
// No match is an empty array, not an error and not null.
|
|
//
|
|
// "Nothing typed" is deliberately absent from this list. It used to be here,
|
|
// and it stopped being a case of "no match" when the endpoint gained an
|
|
// organization context: with nothing typed there is now something to rank —
|
|
// the state of the data — and TestOwliverHighlightsComeFromTheDatabase covers
|
|
// it. A query that WAS typed and matches nothing still answers with nothing,
|
|
// which is the case this test exists for.
|
|
func TestOwliverSuggestionsEmptyResults(t *testing.T) {
|
|
a := newAPI(t)
|
|
|
|
for _, c := range []struct{ name, query string }{
|
|
{"one character", "p"},
|
|
{"irrelevant", "sourdough starter recipe"},
|
|
} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := suggestions(t, a.do("GET", suggestURL("positions", c.query), nil)); len(got) != 0 {
|
|
t.Fatalf("got %v, want none", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A real surface the catalogue holds no readings for is the same answer,
|
|
// typed against or not.
|
|
for _, query := range []string{"owliver", ""} {
|
|
if got := suggestions(t, a.do("GET", suggestURL("settings", query), nil)); len(got) != 0 {
|
|
t.Fatalf("settings returned %v for query %q", got, query)
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── Context ────────────────────────────────────────────────────────────── */
|
|
|
|
// With nothing typed, the suggestions come from what is in PostgreSQL.
|
|
//
|
|
// This is the half of the endpoint that a static catalogue cannot serve: the
|
|
// panel opens having been told nothing, and what it should offer depends on
|
|
// whether this organization has unfinished drafts, unscored candidates or
|
|
// positions nobody has applied to. The assertion is not on WHICH readings come
|
|
// back — that is the catalogue's business and would pin this test to a ranking
|
|
// weight — but that they are real readings, capped, and that the endpoint
|
|
// reaches the database at all.
|
|
func TestOwliverHighlightsComeFromTheDatabase(t *testing.T) {
|
|
a := newAPI(t) // the harness seeds a populated organization
|
|
|
|
got := suggestions(t, a.do("GET", suggestURL("positions", ""), nil))
|
|
if len(got) == 0 {
|
|
t.Fatal("a seeded organization offered nothing with an empty query")
|
|
}
|
|
if len(got) > 3 {
|
|
t.Fatalf("%d suggestions, the cap is 3", len(got))
|
|
}
|
|
for i, s := range got {
|
|
text, _ := s["text"].(string)
|
|
intent, _ := s["intent"].(string)
|
|
if text == "" || intent == "" {
|
|
t.Fatalf("suggestion %d is incomplete: %v", i, s)
|
|
}
|
|
// A highlight is not a shaped request: nothing was typed, so nothing
|
|
// asked for a rendering.
|
|
if _, present := s["capability"]; present {
|
|
t.Fatalf("suggestion %d carries a shape nobody asked for: %v", i, s)
|
|
}
|
|
for key := range s {
|
|
switch key {
|
|
case "text", "intent":
|
|
default:
|
|
t.Fatalf("suggestion %d exposes %q: %v", i, key, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The ranking answers to the data, so changing the data changes the answer.
|
|
//
|
|
// This is the property the whole context read exists for, and the one the panel
|
|
// depends on: a position created through the API must change what Owliver
|
|
// offers afterwards. No seeded posting is a draft, so unfinished drafts are a
|
|
// lever this test owns entirely — one filed here is the only one in the
|
|
// organization, and the endpoint has to notice it.
|
|
//
|
|
// One is the point. A ranking that only reacts to a pile would be a ranking
|
|
// that never reacts to the thing that just happened, which is exactly the stale
|
|
// suggestion this replaced.
|
|
func TestOwliverHighlightsReactToAMutation(t *testing.T) {
|
|
a := newAPI(t)
|
|
|
|
names := func(list []map[string]any) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, s := range list {
|
|
id, _ := s["intent"].(string)
|
|
out[id] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
before := names(suggestions(t, a.do("GET", suggestURL("positions", ""), nil)))
|
|
if before["position-drafts"] {
|
|
t.Skip("the fixture already holds draft positions; this lever is not available")
|
|
}
|
|
|
|
created := a.do("POST", "/api/v1/job-postings", map[string]any{
|
|
"title": "Owliver Context Probe", "status": "draft",
|
|
})
|
|
if created.code != http.StatusCreated {
|
|
t.Fatalf("creating the draft: status %d, body %v", created.code, created.body)
|
|
}
|
|
|
|
after := names(suggestions(t, a.do("GET", suggestURL("positions", ""), nil)))
|
|
if !after["position-drafts"] {
|
|
t.Fatalf("filing a draft did not surface the drafts reading: %v", after)
|
|
}
|
|
}
|
|
|
|
// A talent caller is offered no organization-wide count.
|
|
//
|
|
// The counts behind a highlight are org-wide by construction, and talent's rows
|
|
// are narrowed by the policy table — so answering "eleven candidates are
|
|
// waiting" to someone entitled to see one of them would leak the other ten
|
|
// through an integer. Nothing on the operator pages may reach them.
|
|
func TestOwliverHighlightsAreNotOfferedToTalent(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
for _, page := range []string{"positions", "candidates", "control-center", "analytics"} {
|
|
if got := suggestions(t, r.as(r.talA, "GET", suggestURL(page, ""), nil)); len(got) != 0 {
|
|
t.Fatalf("%s offered talent %v", page, got)
|
|
}
|
|
}
|
|
|
|
// An operator on the same pages is offered something, so the assertion
|
|
// above is about the role rather than about the pages being empty.
|
|
if got := suggestions(t, r.as(r.admin, "GET", suggestURL("positions", ""), nil)); len(got) == 0 {
|
|
t.Fatal("an admin was offered nothing either — the fixture proves nothing")
|
|
}
|
|
}
|
|
|
|
// The page decides the answer, so the same word must not produce the same list
|
|
// everywhere.
|
|
func TestOwliverSuggestionsAreScopedToThePage(t *testing.T) {
|
|
a := newAPI(t)
|
|
|
|
read := func(page string) []string {
|
|
out := []string{}
|
|
for _, s := range suggestions(t, a.do("GET", suggestURL(page, "pipeline"), nil)) {
|
|
out = append(out, s["intent"].(string))
|
|
}
|
|
return out
|
|
}
|
|
|
|
positions, candidates := read("positions"), read("candidates")
|
|
if len(positions) == 0 || len(candidates) == 0 {
|
|
t.Fatalf("positions=%v candidates=%v", positions, candidates)
|
|
}
|
|
if len(positions) == len(candidates) {
|
|
same := true
|
|
for i := range positions {
|
|
if positions[i] != candidates[i] {
|
|
same = false
|
|
break
|
|
}
|
|
}
|
|
if same {
|
|
t.Fatalf("both pages answered pipeline with %v", positions)
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── Authorization ──────────────────────────────────────────────────────── */
|
|
|
|
// Who is asking comes from the session, and it decides which readings exist.
|
|
//
|
|
// Talent may list job applications — but only their own, by a predicate in the
|
|
// repository — so the organization-wide readings the operator console offers
|
|
// are not theirs, and are absent rather than refused.
|
|
func TestOwliverSuggestionsFollowTheCallersRole(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
// A query each page can actually answer, so an empty list means the role
|
|
// was filtered rather than that the words matched nothing.
|
|
operatorPages := []struct{ page, query string }{
|
|
{"control-center", "pipeline attention"},
|
|
{"positions", "pipeline attention"},
|
|
{"candidates", "candidate score"},
|
|
{"hired-history", "recent hires outcomes"},
|
|
{"talent-pool", "talent pool availability"},
|
|
{"activity", "audit unusual activity"},
|
|
}
|
|
|
|
for _, c := range operatorPages {
|
|
for _, act := range []actor{r.admin, r.empA} {
|
|
got := suggestions(t, r.as(act, "GET", suggestURL(c.page, c.query), nil))
|
|
if len(got) == 0 {
|
|
t.Errorf("%s was offered nothing on %s for %q", act.name, c.page, c.query)
|
|
}
|
|
}
|
|
|
|
if got := suggestions(t, r.as(r.talA, "GET", suggestURL(c.page, c.query), nil)); len(got) != 0 {
|
|
t.Errorf("talent was offered %v on %s", got, c.page)
|
|
}
|
|
}
|
|
|
|
// Still 200 with an empty list, never 403: refusing would tell a caller
|
|
// which pages hold readings they cannot have.
|
|
refused := r.as(r.talA, "GET", suggestURL("positions", "pipeline"), nil)
|
|
if refused.code != http.StatusOK {
|
|
t.Fatalf("talent got status %d, want 200 with an empty list", refused.code)
|
|
}
|
|
}
|
|
|
|
// The role filter is not a blanket refusal for talent: what they may genuinely
|
|
// ask — about their own account — is still offered. Without this, the test
|
|
// above would pass with the permission check stubbed out to deny everything.
|
|
func TestOwliverSuggestionsStillServeTalentTheirOwnReadings(t *testing.T) {
|
|
r := newRBAC(t)
|
|
|
|
got := suggestions(t, r.as(r.talA, "GET", suggestURL("profile", "permission"), nil))
|
|
if len(got) == 0 {
|
|
t.Fatal("talent was offered nothing about their own account")
|
|
}
|
|
if got[0]["intent"] != "profile-permissions" {
|
|
t.Fatalf("got %v", got[0])
|
|
}
|
|
}
|
|
|
|
// A permission-sensitive reading: Hired History reads the staff table, which
|
|
// policy.go grants to operators only. Nothing about the request differs — only
|
|
// the session behind it.
|
|
func TestOwliverSuggestionsHideReadingsARoleCannotPerform(t *testing.T) {
|
|
r := newRBAC(t)
|
|
const path = suggestPath + "?page=hired-history&query=recent+hires"
|
|
|
|
for _, act := range []actor{r.admin, r.empA} {
|
|
if got := suggestions(t, r.as(act, "GET", path, nil)); len(got) == 0 {
|
|
t.Errorf("%s was offered no hiring outcomes", act.name)
|
|
}
|
|
}
|
|
if got := suggestions(t, r.as(r.talA, "GET", path, nil)); len(got) != 0 {
|
|
t.Fatalf("talent was offered readings of the staff table: %v", got)
|
|
}
|
|
}
|