aravind changes

This commit is contained in:
2026-08-25 16:37:05 +05:30
parent cadea4bd92
commit b6f8655909
27 changed files with 5058 additions and 163 deletions

View File

@@ -1,6 +1,7 @@
package httpserver
import (
"context"
"encoding/json"
"io"
"net/http"
@@ -130,7 +131,7 @@ func (s *Server) handleCreate(svc *service.Service) http.HandlerFunc {
writeError(w, s.log, err)
return
}
rec, err := svc.Create(r.Context(), ident, body)
rec, err := s.create(r.Context(), svc, ident, body)
if err != nil {
writeError(w, s.log, err)
return
@@ -139,6 +140,25 @@ func (s *Server) handleCreate(svc *service.Service) http.HandlerFunc {
}
}
// create inserts through the resource's own service, except where creating a
// record has a consequence in another table.
//
// One resource has one: an AI interview is only half of completing an
// interview, and the application it names has to be linked in the same
// transaction — see internal/service/interviews.go for why the server performs
// that write and the caller may not. Routing it here rather than registering a
// second endpoint keeps POST /api/v1/ai-interviews the only way to write one,
// which is what the client already calls and what the ownership guard already
// covers.
func (s *Server) create(ctx context.Context, svc *service.Service,
ident authctx.Identity, body domain.Record) (domain.Record, error) {
if svc.Resource().Path == service.InterviewsPath {
return s.workflows.CreateInterview(ctx, ident, body)
}
return svc.Create(ctx, ident, body)
}
func (s *Server) handleUpdate(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ident, ok := s.authorize(w, r, svc, domain.OpUpdate)
@@ -174,11 +194,6 @@ func (s *Server) handleDelete(svc *service.Service) http.HandlerFunc {
}
}
// decodeBody reads a JSON object body.
//
// DisallowUnknownFields is not used — the target is a map, so every field is
// "known" here. Unknown *columns* are rejected in the service, where the
// resource's schema is available to say which those are.
// decodeInto reads a JSON body into a typed struct.
//
// Beside decodeBody rather than replacing it: the resource handlers genuinely
@@ -200,6 +215,11 @@ func decodeInto(r *http.Request, dst any) error {
return nil
}
// decodeBody reads a JSON object body.
//
// DisallowUnknownFields is not used — the target is a map, so every field is
// "known" here. Unknown *columns* are rejected in the service, where the
// resource's schema is available to say which those are.
func decodeBody(r *http.Request) (domain.Record, error) {
defer func() { _ = r.Body.Close() }()
raw, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, maxBodyBytes))

View File

@@ -42,27 +42,30 @@ const sessionCookieName = "krow_session"
// that never authenticate anything.
func (s *Server) secureCookies() bool { return s.cfg.AppEnv != "development" }
// sameSite resolves the configured SameSite mode.
// sessionSameSite reports the SameSite mode the session cookie must carry.
//
// Lax remains the default and the recommendation. "none" exists for the one
// deployment shape that cannot work without it: a frontend on a different
// registrable domain from the API. In that case Lax withholds the cookie on
// every cross-site fetch, so the sign-in succeeds, the Set-Cookie arrives, and
// the next request carries nothing — which reads as a broken session rather
// than as a cookie policy.
// Lax is the default and the safer value: it closes the CSRF hole by refusing
// to travel on cross-site subresource requests. That is exactly right when the
// page and the API share an origin, which is the supported deployment.
//
// An unrecognised value falls back to Lax rather than to None. config.validate
// rejects those before startup, so this is only a belt-and-braces default in
// the safe direction.
func (s *Server) sameSite() http.SameSite {
switch s.cfg.HTTP.CookieSameSite {
case "none":
// When the API is configured with a CORS allowlist, the deployment is by
// definition the other one: a page on some other origin calls this API
// directly. A Lax cookie is never sent on those requests, so login would
// succeed once and every request after it would arrive anonymous. None is the
// only mode a browser will send cross-site, and it requires Secure — which is
// why an origin allowlist forces Secure on regardless of AppEnv.
func (s *Server) sessionSameSite() http.SameSite {
if len(s.cfg.HTTP.CORSOrigins) > 0 {
return http.SameSiteNoneMode
case "strict":
return http.SameSiteStrictMode
default:
return http.SameSiteLaxMode
}
return http.SameSiteLaxMode
}
// crossSiteCookies reports whether the cookie must be marked Secure because it
// has to travel cross-site. SameSite=None without Secure is rejected outright
// by every current browser.
func (s *Server) crossSiteCookies() bool {
return s.sessionSameSite() == http.SameSiteNoneMode
}
// setSessionCookie writes the raw token to the browser.
@@ -82,15 +85,13 @@ func (s *Server) setSessionCookie(w http.ResponseWriter, token string, lifetime
Path: "/",
// HttpOnly: script cannot read it.
HttpOnly: true,
// Lax by default, and Strict/None available through
// HTTP_COOKIE_SAMESITE. Strict would drop the cookie on any cross-site
// navigation, so following a link into the app would land on a login
// page despite a live session. None sends it on cross-site requests,
// which is the CSRF hole Lax exists to close — and is nonetheless the
// only workable value when the frontend is on a different registrable
// domain. See Server.sameSite.
SameSite: s.sameSite(),
Secure: s.secureCookies(),
// Lax, not Strict and not None. Strict would drop the cookie on any
// cross-site navigation, so following a link into the app would land on
// a login page despite a live session. None would require Secure and
// would send the cookie on cross-site POSTs, which is the CSRF hole Lax
// exists to close.
SameSite: s.sessionSameSite(),
Secure: s.secureCookies() || s.crossSiteCookies(),
MaxAge: int(lifetime.Seconds()),
})
}
@@ -107,10 +108,8 @@ func (s *Server) clearSessionCookie(w http.ResponseWriter) {
Value: "",
Path: "/",
HttpOnly: true,
// Must match the attributes it was set with, SameSite included, or the
// browser treats this as a different cookie and leaves the original.
SameSite: s.sameSite(),
Secure: s.secureCookies(),
SameSite: s.sessionSameSite(),
Secure: s.secureCookies() || s.crossSiteCookies(),
MaxAge: -1,
})
}

View File

@@ -72,6 +72,12 @@ func cors(origins []string) func(http.Handler) http.Handler {
}
w.Header().Set("Access-Control-Allow-Origin", origin)
// The frontend sends `credentials: "include"`, and a browser
// discards any response to such a request that does not carry this
// header — preflight included. Safe only because the origin was
// matched exactly above and is echoed back one at a time; "*" is
// never sent, which is the pairing the spec forbids.
w.Header().Set("Access-Control-Allow-Credentials", "true")
// Authentication is a cookie, so the browser will neither send it
// nor expose the response without this. It is set for allowlisted

View File

@@ -0,0 +1,239 @@
package httpserver_test
import (
"context"
"net/http"
"testing"
)
// Completing an AI interview.
//
// The endpoint is unchanged — POST /api/v1/ai-interviews, the one the modal
// already calls — but finishing an interview is two writes, and the second one
// is a write the caller who most often makes the request may not perform. The
// tests below are about that seam: the interview and the link land together,
// they land for a talent user, and nothing about talent's own permissions has
// widened to make it possible.
// interviewCount counts the organization's interview rows.
func interviewCount(t *testing.T, r *rbac) int {
t.Helper()
return countRows(t, r, "ai_interviews")
}
// talentApplication files an application through the API as the talent user, so
// its email is whatever the server derived rather than what a test asked for.
func talentApplication(t *testing.T, r *rbac, who actor) string {
t.Helper()
return mustCreate(t, r, who, "/api/v1/job-applications", map[string]any{
"job_posting_id": r.activePosting,
"applicant_name": who.name,
})
}
func interviewBody(applicationID, postingID string, score any) map[string]any {
body := map[string]any{
"application_id": applicationID,
"job_posting_id": postingID,
"job_title": "Open Role",
"candidate_name": "Candidate",
"messages": []map[string]any{
{"role": "assistant", "content": "Tell me about a difficult shift."},
{"role": "user", "content": "We were two people short and I re-planned the passes."},
},
"verdict": "hire",
"hire_recommendation": "Hire",
"summary": "Composed under pressure.",
}
if score != nil {
body["overall_interview_score"] = score
}
return body
}
/* ── The RBAC break this fixes ──────────────────────────────────────────── */
// A talent user completing their own interview is the whole talent flow, and it
// could not finish: ai-interviews:Create is open to everyone, job-applications:
// Update is operators only, so the interview was written and the application
// never learned about it. Both writes now happen server-side, in one
// transaction, on the row the interview already names.
func TestTalentCompletesTheirOwnInterview(t *testing.T) {
r := newRBAC(t)
app := talentApplication(t, r, r.talA)
got := r.as(r.talA, "POST", "/api/v1/ai-interviews",
interviewBody(app, r.activePosting, 88))
if got.code != http.StatusCreated {
t.Fatalf("talent interview: got %d, want 201 (%v)", got.code, got.body)
}
interview := got.body["data"].(map[string]any)
interviewID, _ := interview["id"].(string)
if interviewID == "" {
t.Fatalf("the response carries no interview id: %v", got.body)
}
// The response is still the interview record, unchanged.
if interview["application_id"] != app {
t.Errorf("data.application_id = %v, want %s", interview["application_id"], app)
}
stored := applicationByID(t, r, app)
if stored["status"] != "interview" {
t.Errorf("application.status = %v, want interview — the analytics count "+
"status === 'interview' || interview_id", stored["status"])
}
if stored["interview_id"] != interviewID {
t.Errorf("application.interview_id = %v, want %s", stored["interview_id"], interviewID)
}
if score, ok := stored["ai_score"].(float64); !ok || int(score) != 88 {
t.Errorf("application.ai_score = %v, want the interview's 88", stored["ai_score"])
}
}
// And the permission itself has NOT widened. The server writes that one row on
// the caller's behalf; the caller still cannot patch an application.
func TestCompletingAnInterviewDoesNotWidenApplicationUpdate(t *testing.T) {
r := newRBAC(t)
app := talentApplication(t, r, r.talA)
if got := r.as(r.talA, "POST", "/api/v1/ai-interviews",
interviewBody(app, r.activePosting, 70)); got.code != http.StatusCreated {
t.Fatalf("talent interview: got %d, want 201 (%v)", got.code, got.body)
}
if got := r.as(r.talA, "PATCH", "/api/v1/job-applications/"+app,
map[string]any{"status": "hired"}); got.code != http.StatusForbidden {
t.Fatalf("talent PATCH of their own application: got %d, want 403 (%v)", got.code, got.body)
}
}
// An operator's interview links the same way. The atomicity half of the fix is
// not talent-specific: a failure between the two writes left an interview
// attached to an application that did not know about it, whoever ran it.
func TestOperatorCompletingAnInterviewLinksTheApplication(t *testing.T) {
r := newRBAC(t)
app := applicationFor(t, r, r.activePosting, "Operator Candidate", "opcand@example.test")
got := r.as(r.empA, "POST", "/api/v1/ai-interviews",
interviewBody(app, r.activePosting, 64))
if got.code != http.StatusCreated {
t.Fatalf("operator interview: got %d, want 201 (%v)", got.code, got.body)
}
interviewID := got.body["data"].(map[string]any)["id"].(string)
stored := applicationByID(t, r, app)
if stored["status"] != "interview" || stored["interview_id"] != interviewID {
t.Errorf("application = status %v, interview_id %v; want interview / %s",
stored["status"], stored["interview_id"], interviewID)
}
}
/* ── What the link must not do ──────────────────────────────────────────── */
// A body that says nothing about the score must not overwrite the screening
// score with the interview column's default of 0. The field the caller never
// mentioned is not a value they asked to store.
func TestInterviewWithoutAScoreLeavesTheApplicationScore(t *testing.T) {
r := newRBAC(t)
app := applicationFor(t, r, r.activePosting, "Scored", "scored@example.test") // ai_score 77
got := r.as(r.admin, "POST", "/api/v1/ai-interviews",
interviewBody(app, r.activePosting, nil))
if got.code != http.StatusCreated {
t.Fatalf("interview: got %d, want 201 (%v)", got.code, got.body)
}
stored := applicationByID(t, r, app)
if score, ok := stored["ai_score"].(float64); !ok || int(score) != 77 {
t.Errorf("application.ai_score = %v, want the screening score 77 left alone",
stored["ai_score"])
}
// The status and the link still move — those are what completing an
// interview means.
if stored["status"] != "interview" || stored["interview_id"] == nil {
t.Errorf("application = status %v, interview_id %v; want interview and a link",
stored["status"], stored["interview_id"])
}
}
// Somebody else's application is not a subject a talent user may interview for,
// and the refusal must leave nothing behind — not the interview, and not a
// changed application.
func TestInterviewForAnotherPersonsApplicationWritesNothing(t *testing.T) {
r := newRBAC(t)
app := talentApplication(t, r, r.talA)
before := interviewCount(t, r)
got := r.as(r.talB, "POST", "/api/v1/ai-interviews",
interviewBody(app, r.activePosting, 95))
if got.code != http.StatusNotFound {
t.Fatalf("interview for another person's application: got %d, want 404 (%v)",
got.code, got.body)
}
if after := interviewCount(t, r); after != before {
t.Errorf("ai_interviews: %d -> %d, want no row", before, after)
}
stored := applicationByID(t, r, app)
if stored["status"] != "applied" || stored["interview_id"] != nil {
t.Errorf("application = status %v, interview_id %v; want it untouched",
stored["status"], stored["interview_id"])
}
}
// An interview that cannot be written must not move the application either.
// Both writes are in one transaction, so a refusal at the first is the whole
// request rolled back rather than a partial completion.
func TestARefusedInterviewLeavesTheApplicationAlone(t *testing.T) {
r := newRBAC(t)
app := applicationFor(t, r, r.activePosting, "Unfinished", "unfinished@example.test")
before := interviewCount(t, r)
body := interviewBody(app, r.activePosting, 80)
body["verdict"] = "definitely" // outside the interview_verdict enum
got := r.as(r.admin, "POST", "/api/v1/ai-interviews", body)
if got.code == http.StatusCreated {
t.Fatalf("an invalid verdict was accepted: %v", got.body)
}
if after := interviewCount(t, r); after != before {
t.Errorf("ai_interviews: %d -> %d, want no row", before, after)
}
stored := applicationByID(t, r, app)
if stored["status"] != "shortlisted" || stored["interview_id"] != nil {
t.Errorf("application = status %v, interview_id %v; want it untouched",
stored["status"], stored["interview_id"])
}
}
// Cross-tenant: the application is in another organization, so it is absent
// rather than forbidden, and no interview is written for it.
func TestInterviewCannotReachAnotherOrganizationsApplication(t *testing.T) {
r := newRBAC(t)
app := applicationFor(t, r, r.activePosting, "Ours", "ours-interview@example.test")
var before int
if err := r.h.Pool.QueryRow(context.Background(),
`SELECT count(*) FROM ai_interviews`).Scan(&before); err != nil {
t.Fatalf("count interviews: %v", err)
}
got := r.as(r.outsider, "POST", "/api/v1/ai-interviews",
interviewBody(app, r.activePosting, 90))
if got.code == http.StatusCreated {
t.Fatalf("an outsider wrote an interview for our application: %v", got.body)
}
var after int
if err := r.h.Pool.QueryRow(context.Background(),
`SELECT count(*) FROM ai_interviews`).Scan(&after); err != nil {
t.Fatalf("count interviews: %v", err)
}
if after != before {
t.Errorf("ai_interviews: %d -> %d, want no row", before, after)
}
if stored := applicationByID(t, r, app); stored["interview_id"] != nil {
t.Errorf("application.interview_id = %v, want it untouched", stored["interview_id"])
}
}

View File

@@ -0,0 +1,61 @@
package httpserver
import (
"net/http"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/domain"
"github.com/krow/krow-backend/go-api/internal/owliver"
)
// routeOwliver registers the Owliver panel's suggestion endpoint.
//
// GET, and a query string rather than a body, because the request is a read
// with no side effect and the panel issues one per keystroke: a GET is what
// makes it retryable, cancellable and cacheable by anything in front of it.
//
// It is deliberately NOT on the publicPaths allowlist in auth.go. Which
// readings exist depends on the caller's role, so an anonymous suggestion has
// no meaning — and the allowlist's failure mode is a route that refuses
// everyone, which is the direction this should fall in.
func (s *Server) routeOwliver(mux *http.ServeMux) int {
mux.HandleFunc("GET /api/v1/owliver/suggestions", s.handleOwliverSuggestions)
return 1
}
// suggestionsBody is the payload inside the standard data envelope.
//
// An object rather than a bare array, so the response has somewhere to grow — a
// future `truncated` or `context` field would otherwise be a breaking change to
// a client already reading `data` as a list.
type suggestionsBody struct {
Suggestions []owliver.Suggestion `json:"suggestions"`
}
// handleOwliverSuggestions answers what the caller could usefully ask here.
//
// There is no s.authorize call and no policy lookup in this handler, and that
// is the design rather than an omission: this endpoint exposes no resource, so
// there is no operation to gate. Authorization happens per suggestion, inside
// the catalogue, against the same domain.Policy table every other endpoint
// consults — a reading the caller could not perform is never ranked, so it
// cannot be returned. Authentication is upstream, in the middleware.
func (s *Server) handleOwliverSuggestions(w http.ResponseWriter, r *http.Request) {
ident, err := authctx.MustFrom(r.Context())
if err != nil {
// Unreachable: the middleware refuses an unauthenticated request before
// the router sees it. A missing identity here is a wiring bug.
writeError(w, s.log, domain.Internal(err))
return
}
params, err := s.suggestions.ParseParams(r.URL.Query())
if err != nil {
writeError(w, s.log, err)
return
}
writeJSON(w, http.StatusOK, envelope{
Data: suggestionsBody{Suggestions: s.suggestions.Suggest(ident, params)},
})
}

View File

@@ -0,0 +1,315 @@
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.
func TestOwliverSuggestionsEmptyResults(t *testing.T) {
a := newAPI(t)
for _, c := range []struct{ name, query string }{
{"nothing typed", ""},
{"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.
if got := suggestions(t, a.do("GET", suggestURL("settings", "owliver"), nil)); len(got) != 0 {
t.Fatalf("settings returned %v", got)
}
}
// 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)
}
}

View File

@@ -314,6 +314,50 @@ func mustCreate(t *testing.T, r *rbac, act actor, path string, body map[string]a
/* ── 3. Mass assignment ─────────────────────────────────────────────────── */
// The other half of a talent-only derivation: what an OPERATOR must supply.
//
// The server fills these columns from the session for a talent caller and for
// nobody else — an operator filing an application or logging evidence is
// writing about somebody who is not them. Treating the column as
// server-supplied for every role let an operator's request past validation and
// into SQL, where it came back as a not-null violation instead of the
// required-field message the contract promises. The two halves have to agree:
// what the repository will derive, and what validation stops asking for.
func TestTalentOnlyDerivedFieldsAreRequiredOfOperators(t *testing.T) {
r := newRBAC(t)
cases := []struct {
name, path, column string
body map[string]any
}{
{"job_applications.email", "/api/v1/job-applications", "email",
map[string]any{"job_posting_id": r.activePosting, "applicant_name": "Nameless"}},
{"evidence.worker_email", "/api/v1/evidence", "worker_email",
map[string]any{"type": "photo_identify"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := r.as(r.admin, "POST", tc.path, tc.body)
if got.code != http.StatusUnprocessableEntity {
t.Fatalf("operator create without %s: got %d, want 422 (%v)",
tc.column, got.code, got.body)
}
details, _ := got.body["error"].(map[string]any)["details"].(map[string]any)
if details[tc.column] != "required" {
t.Errorf("details = %v, want %s: required", details, tc.column)
}
// The same body from a talent caller is complete, because the
// server is about to fill the column in from their session.
if got := r.as(r.talA, "POST", tc.path, tc.body); got.code != http.StatusCreated {
t.Errorf("talent create without %s: got %d, want 201 (%v)",
tc.column, got.code, got.body)
}
})
}
}
// Identity a caller supplies is ignored; identity the server derives wins.
//
// This is the test that makes the ownership predicates above mean anything. If

View File

@@ -1,17 +1,24 @@
// Package httpserver holds the HTTP surface.
//
// It serves /health, the sign-in endpoints, the entity endpoints described in
// docs/api-contract.md, and the current-user endpoints.
// docs/api-contract.md, the current-user endpoints, the agent and skill
// definition endpoints, and the Owliver panel's suggestion endpoint.
//
// Phase 3C replaced the development identity with real authentication. Every
// request outside the small public allowlist in auth.go must carry a session
// cookie; the middleware resolves it to a user row and puts that user, and
// their organization, on the request context. Nothing downstream changed —
// every service and repository already took the organization as a parameter,
// which is what devOrgMiddleware existed to make true.
// Authentication replaced the development identity: every request outside the
// small public allowlist in auth.go must carry a session cookie; the middleware
// resolves it to a user row and puts that user, and their organization, on the
// request context. Nothing downstream changed — every service and repository
// already took the organization as a parameter, which is what devOrgMiddleware
// existed to make true.
//
// Authorization is NOT here. A signed-in user reaches every endpoint they could
// reach before; deciding which roles may do what is Phase 3D.
// Authorization is here, in Server.authorize: it reads the role off the
// authenticated identity, consults the deny-by-default policy table in
// internal/domain/policy.go, and answers 403 before any query runs. Row
// visibility — organization scope, and ownership for talent callers — is a SQL
// predicate in internal/repo instead, so an invisible row answers 404 rather
// than 403. The definition endpoints are the exception: they are not
// domain.Resource values, so their role checks are written inline in
// internal/service/definitions.go rather than in the policy table.
package httpserver
import (
@@ -38,6 +45,7 @@ type Server struct {
api *service.Registry
definitions *service.DefinitionsService
workflows *service.WorkflowService
suggestions *service.SuggestionsService
log *slog.Logger
http *http.Server
started time.Time
@@ -126,6 +134,7 @@ func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option)
api: service.NewRegistry(database.Pool),
definitions: service.NewDefinitions(database.Pool),
workflows: service.NewWorkflows(database.Pool).WithClock(o.now),
suggestions: service.NewSuggestions(),
started: o.now(),
sessions: sessions,
users: users,
@@ -138,7 +147,7 @@ func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option)
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
s.endpoints = s.routeAuth(mux) + s.routeResources(mux) + s.routeMe(mux) +
s.routeDefinitions(mux) + s.routeWorkflows(mux)
s.routeDefinitions(mux) + s.routeWorkflows(mux) + s.routeOwliver(mux)
handler := jsonErrors(mux)
// Authentication sits where devOrgMiddleware used to, so every route below

View File

@@ -128,6 +128,11 @@ func (s *Server) handleAssign(w http.ResponseWriter, r *http.Request) {
ident, ok := s.authorizeAll(w, r,
requirement{"assignments", domain.OpCreate},
requirement{"job-applications", domain.OpUpdate},
// The workflow may now FILE an application as well as patch one, for a
// worker placed on a posting they never applied to. A write the handler
// performs has to appear in the list it is authorized against, even
// when — as here — the resulting permission set is unchanged.
requirement{"job-applications", domain.OpCreate},
requirement{"user-activity", domain.OpCreate},
)
if !ok {

View File

@@ -329,3 +329,326 @@ func TestWorkflowEndpointsRequireASession(t *testing.T) {
}
}
}
/* ── Activity vocabulary ────────────────────────────────────────────────── */
// activityTypes returns the event types written about one worker, newest first.
//
// Read straight from the table rather than through GET /user-activity so the
// assertion is about what was STORED. The frontend's anomaly detection reads
// these strings — PRIVILEGED_EVENTS is ['hire_candidate', 'create_position'] —
// and a value the vocabulary does not contain is not a different label, it is
// an event that silently stops counting.
func activityTypes(t *testing.T, r *rbac, workerEmail string) []string {
t.Helper()
rows, err := r.h.Pool.Query(context.Background(),
`SELECT event_type FROM user_activity
WHERE org_id = $1::uuid AND worker_email = $2::citext
ORDER BY created_date DESC, id DESC`, r.orgID, workerEmail)
if err != nil {
t.Fatalf("read user_activity: %v", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
t.Fatalf("scan user_activity: %v", err)
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
t.Fatalf("read user_activity: %v", err)
}
return out
}
func TestHireWritesTheFrontendsActivityEvent(t *testing.T) {
r := newRBAC(t)
app := applicationFor(t, r, r.activePosting, "Evented", "evented@example.test")
if got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire",
map[string]any{}); got.code != http.StatusCreated {
t.Fatalf("hire: got %d, want 201 (%v)", got.code, got.body)
}
events := activityTypes(t, r, "evented@example.test")
if len(events) != 1 || events[0] != "hire_candidate" {
t.Errorf("activity = %v, want exactly [hire_candidate] — the vocabulary "+
"activitySignals.js reads, and the one the seed fixture uses", events)
}
}
func TestAssignWritesTheFrontendsActivityEvent(t *testing.T) {
r := newRBAC(t)
if got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
map[string]any{"workers": []map[string]any{
{"worker_email": "evented-assign@example.test", "worker_name": "Evented",
"starts_at": "2026-09-01T09:00:00Z"},
}}); got.code != http.StatusCreated {
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
}
events := activityTypes(t, r, "evented-assign@example.test")
if len(events) != 1 || events[0] != "assign_employee" {
t.Errorf("activity = %v, want exactly [assign_employee]", events)
}
}
/* ── Assign: source ─────────────────────────────────────────────────────── */
// An unspecified source must mean what the column says it means. The default in
// 000001 is `owliver` and the frontend sends `owliver`; substituting `manual`
// made a row written through this endpoint disagree with a row written through
// POST /assignments about where the same action came from.
func TestAssignDefaultsSourceToTheColumnDefault(t *testing.T) {
r := newRBAC(t)
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
map[string]any{"workers": []map[string]any{
{"worker_email": "default-source@example.test", "starts_at": "2026-09-01T09:00:00Z"},
{"worker_email": "explicit-source@example.test", "starts_at": "2026-09-01T09:00:00Z",
"source": "manual"},
}})
if got.code != http.StatusCreated {
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
}
created := assignmentsOf(t, got)
if created[0]["source"] != "owliver" {
t.Errorf("source = %v, want owliver", created[0]["source"])
}
// An explicit value is still the caller's.
if created[1]["source"] != "manual" {
t.Errorf("source = %v, want the supplied manual", created[1]["source"])
}
}
// assignmentsOf reads the assignment records out of an assign response.
func assignmentsOf(t *testing.T, got response) []map[string]any {
t.Helper()
data, _ := got.body["data"].(map[string]any)
raw, _ := data["assignments"].([]any)
if raw == nil {
t.Fatalf("response carries no assignments: %v", got.body)
}
out := make([]map[string]any, 0, len(raw))
for _, rec := range raw {
out = append(out, rec.(map[string]any))
}
return out
}
// applicationByID reads one application as an operator, or fails.
func applicationByID(t *testing.T, r *rbac, id string) map[string]any {
t.Helper()
list := r.as(r.admin, "GET", "/api/v1/job-applications?limit=500", nil)
if list.code != http.StatusOK {
t.Fatalf("list applications: %d (%v)", list.code, list.body)
}
for _, raw := range list.body["data"].([]any) {
rec := raw.(map[string]any)
if rec["id"] == id {
return rec
}
}
t.Fatalf("application %s not found", id)
return nil
}
/* ── Assign: the application a worker does not have yet ─────────────────── */
// The behaviour the frontend had and the endpoint did not.
//
// An application is what puts a person in the pipeline for a role: the
// candidate record is addressed by it and an interview takes one as its
// subject. A worker assigned from the talent pool has none, so the endpoint has
// to file one — in the same transaction as the assignment, which is the half
// the frontend could not do.
func TestAssignCreatesTheApplicationItNeeds(t *testing.T) {
r := newRBAC(t)
appsBefore := countRows(t, r, "job_applications")
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
map[string]any{"workers": []map[string]any{{
"worker_email": "from-pool@example.test",
"worker_name": "Pool Worker",
"starts_at": "2026-09-01T09:00:00Z",
"match_score": 88,
"application": map[string]any{
"job_title": "Open Role",
"phone": "555-0100",
"years_experience": 4,
"skills": []string{"service", "bar"},
"professional_summary": "Placed from the talent pool.",
"ai_score": 88,
},
}}})
if got.code != http.StatusCreated {
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
}
if after := countRows(t, r, "job_applications"); after != appsBefore+1 {
t.Fatalf("job_applications: %d -> %d, want exactly one more", appsBefore, after)
}
assignment := assignmentsOf(t, got)[0]
linked, _ := assignment["application_id"].(string)
if linked == "" {
t.Fatal("the assignment was not linked to the application that was created for it")
}
app := applicationByID(t, r, linked)
if app["status"] != "assigned" {
t.Errorf("application.status = %v, want assigned", app["status"])
}
if app["email"] != "from-pool@example.test" {
t.Errorf("application.email = %v, want the worker's email", app["email"])
}
if app["job_posting_id"] != r.activePosting {
t.Errorf("application.job_posting_id = %v, want the posting being assigned to", app["job_posting_id"])
}
// applicant_name falls back to the worker's name rather than being blank —
// the column has a not-blank check.
if app["applicant_name"] != "Pool Worker" {
t.Errorf("application.applicant_name = %v, want the worker's name", app["applicant_name"])
}
if app["phone"] != "555-0100" {
t.Errorf("application.phone = %v, want the supplied phone", app["phone"])
}
if score, ok := app["ai_score"].(float64); !ok || int(score) != 88 {
t.Errorf("application.ai_score = %v, want 88", app["ai_score"])
}
// The audit entry names the application, so the feed can open it.
var activityApp *string
if err := r.h.Pool.QueryRow(context.Background(),
`SELECT application_id::text FROM user_activity
WHERE org_id = $1::uuid AND worker_email = $2::citext`,
r.orgID, "from-pool@example.test").Scan(&activityApp); err != nil {
t.Fatalf("read the activity entry: %v", err)
}
if activityApp == nil || *activityApp != linked {
t.Errorf("activity.application_id = %v, want %s", activityApp, linked)
}
}
// (job_posting_id, email) is UNIQUE, so the second assign of the same person to
// the same posting must find the application rather than try to file another —
// and the comparison is case-insensitive, because the column is citext and the
// frontend's own lookup lowercased both sides.
func TestAssignLinksAnExistingApplicationInsteadOfDuplicating(t *testing.T) {
r := newRBAC(t)
existing := applicationFor(t, r, r.activePosting, "Already Applied", "Already.Applied@example.test")
appsBefore := countRows(t, r, "job_applications")
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
map[string]any{"workers": []map[string]any{{
"worker_email": "already.applied@example.test",
"worker_name": "Already Applied",
"starts_at": "2026-09-01T09:00:00Z",
"application": map[string]any{"job_title": "Open Role"},
}}})
if got.code != http.StatusCreated {
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
}
if after := countRows(t, r, "job_applications"); after != appsBefore {
t.Errorf("job_applications: %d -> %d, want no new row for a person who already applied",
appsBefore, after)
}
if linked := assignmentsOf(t, got)[0]["application_id"]; linked != existing {
t.Errorf("assignment.application_id = %v, want the existing application %s", linked, existing)
}
if app := applicationByID(t, r, existing); app["status"] != "assigned" {
t.Errorf("application.status = %v, want assigned", app["status"])
}
}
// An id the caller already has still wins over the payload: it is a decision
// they have made, and honouring the payload instead could file a second
// application for the same placement.
func TestAssignPrefersTheSuppliedApplicationID(t *testing.T) {
r := newRBAC(t)
app := applicationFor(t, r, r.activePosting, "Named", "named@example.test")
appsBefore := countRows(t, r, "job_applications")
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
map[string]any{"workers": []map[string]any{{
"worker_email": "named@example.test",
"worker_name": "Named",
"starts_at": "2026-09-01T09:00:00Z",
"application_id": app,
"application": map[string]any{"applicant_name": "Ignored"},
}}})
if got.code != http.StatusCreated {
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
}
if after := countRows(t, r, "job_applications"); after != appsBefore {
t.Errorf("job_applications: %d -> %d, want no new row", appsBefore, after)
}
if linked := assignmentsOf(t, got)[0]["application_id"]; linked != app {
t.Errorf("assignment.application_id = %v, want %s", linked, app)
}
if stored := applicationByID(t, r, app); stored["applicant_name"] != "Named" {
t.Errorf("applicant_name = %v — the payload overwrote a named application",
stored["applicant_name"])
}
}
// No payload, no application. A worker placed straight from the workforce is
// legitimate, and one must not be invented for them.
func TestAssignWithoutAnApplicationPayloadLinksNothing(t *testing.T) {
r := newRBAC(t)
appsBefore := countRows(t, r, "job_applications")
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
map[string]any{"workers": []map[string]any{
{"worker_email": "unattached@example.test", "worker_name": "Unattached",
"starts_at": "2026-09-01T09:00:00Z"},
}})
if got.code != http.StatusCreated {
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
}
if after := countRows(t, r, "job_applications"); after != appsBefore {
t.Errorf("job_applications: %d -> %d, want no application invented", appsBefore, after)
}
if linked := assignmentsOf(t, got)[0]["application_id"]; linked != nil {
t.Errorf("assignment.application_id = %v, want null", linked)
}
}
// The application payload is validated exactly as POST /job-applications would
// validate it, and the batch element that produced the complaint is named.
func TestAssignValidatesTheApplicationPayload(t *testing.T) {
r := newRBAC(t)
assignBefore := countRows(t, r, "assignments")
appsBefore := countRows(t, r, "job_applications")
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
map[string]any{"workers": []map[string]any{
{"worker_email": "good@example.test", "worker_name": "Good",
"starts_at": "2026-09-01T09:00:00Z",
"application": map[string]any{"job_title": "Open Role"}},
{"worker_email": "bad@example.test", "worker_name": "Bad",
"starts_at": "2026-09-01T09:00:00Z",
"application": map[string]any{"english_level": "telepathic"}},
}})
if got.code != http.StatusUnprocessableEntity {
t.Fatalf("assign with an invalid application: got %d, want 422 (%v)", got.code, got.body)
}
details, _ := got.body["error"].(map[string]any)["details"].(map[string]any)
if details["workers[1].english_level"] == nil {
t.Errorf("details = %v, want the failure attributed to workers[1]", details)
}
// And the first worker — whose application WAS filed before the second
// failed — must be gone with it.
if after := countRows(t, r, "job_applications"); after != appsBefore {
t.Errorf("job_applications: %d -> %d — a rejected batch left an application behind",
appsBefore, after)
}
if after := countRows(t, r, "assignments"); after != assignBefore {
t.Errorf("assignments: %d -> %d — a rejected batch left an assignment behind",
assignBefore, after)
}
}