aravind changes
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
239
go-api/internal/httpserver/interviews_test.go
Normal file
239
go-api/internal/httpserver/interviews_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
61
go-api/internal/httpserver/owliver.go
Normal file
61
go-api/internal/httpserver/owliver.go
Normal 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)},
|
||||
})
|
||||
}
|
||||
315
go-api/internal/httpserver/owliver_test.go
Normal file
315
go-api/internal/httpserver/owliver_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user