CORS
cors.go never set Access-Control-Allow-Credentials, so the
cookie-authenticated API was unreadable from any cross-origin frontend:
the server answered correctly and the browser blocked the page from
reading it. Set for allowlisted origins on both the preflight and the
actual response. Three tests added.
HTTP_COOKIE_SAMESITE (lax|none|strict, default lax) is new. CORS is only
half of what a cross-origin browser call needs; SameSite is judged on
registrable domain, so a frontend on an unrelated domain gets perfect CORS
headers and still no cookie. "none" is the only value that survives that,
and validate() refuses it without the Secure flag.
The "*" rejection now explains itself: browsers refuse Allow-Origin "*"
together with credentials, so it would break every authenticated call
rather than loosen anything.
Transactional endpoints (api-contract.md 12.1)
POST /api/v1/job-applications/{id}/hire
POST /api/v1/job-postings/{id}/assignments
Replaces two client-side loops that wrote several records with no
transaction and no rollback. Each is now one endpoint and one transaction,
built over repo.Repo so org scoping, derived columns, type casts and error
translation are not re-derived. Authorization reuses the existing policy
table rather than adding a parallel one: a workflow is exactly as
privileged as the writes it performs. 13 tests, including both rollback
paths.
Bug fix in the repository layer
repo.bindValue handled int64/int/float64/string but not int32, which is
what pgx returns for a PostgreSQL `int` column. Nothing previously read a
record and wrote one of its fields elsewhere, so it never surfaced; the
hire flow does exactly that and failed with "ai_score must be a number".
Both KindInt and KindFloat now accept the widths pgx actually produces.
Deployment
infrastructure/Dockerfile.api multi-stage, cross-compiling (BUILDPLATFORM
+ GOARCH) so linux/amd64 builds from arm64 are compiled rather than
emulated. Alpine runtime, non-root uid 10001, 22.1 MB. Ships api, seed,
setpassword and migrate, plus the migrations, so a Kubernetes
initContainer can apply the schema from the same image and tag as the
API. HEALTHCHECK keys on status code, not body, so a "degraded" instance
is not pulled from rotation during a migration window.
infrastructure/docker-compose.yml migrations run to completion before the
API starts. Assumes a managed PostgreSQL; the local-db overlay adds one
with TLS enabled so APP_ENV=production is met rather than dodged.
scripts/drop_public_tables.go the one-off used to clear an unrelated
schema from krowdb on 2026-08-24, kept for the record. Build-tagged
ignore and gated on CONFIRM_DROP=yes.
Verified against PostgreSQL: 16/16 new tests pass, and the image was built,
run and exercised end to end (login, CORS preflight, authenticated reads,
transaction rollback).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmQiGq73Uyfq7J4yR8Vxxw
332 lines
13 KiB
Go
332 lines
13 KiB
Go
package httpserver_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
// The multi-record endpoints from api-contract.md §12.1.
|
|
//
|
|
// The property worth testing here is not that the happy path works — it is that
|
|
// a failure part-way through leaves NOTHING behind. Every rollback test below
|
|
// counts rows before and after, because "the request returned an error" and
|
|
// "the request changed nothing" are different claims and only the second one is
|
|
// what a transaction is for.
|
|
|
|
// applicationFor creates an application that can be hired.
|
|
func applicationFor(t *testing.T, r *rbac, posting, name, email string) string {
|
|
t.Helper()
|
|
return mustCreate(t, r, r.admin, "/api/v1/job-applications", map[string]any{
|
|
"job_posting_id": posting,
|
|
"applicant_name": name,
|
|
"email": email,
|
|
"status": "shortlisted",
|
|
"ai_score": 77,
|
|
})
|
|
}
|
|
|
|
func countRows(t *testing.T, r *rbac, table string) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := r.h.Pool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM `+table+` WHERE org_id = $1::uuid`, r.orgID).Scan(&n); err != nil {
|
|
t.Fatalf("count %s: %v", table, err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
/* ── Hire ───────────────────────────────────────────────────────────────── */
|
|
|
|
func TestHireCreatesStaffAndMovesApplication(t *testing.T) {
|
|
r := newRBAC(t)
|
|
app := applicationFor(t, r, r.activePosting, "Hire Me", "hire-me@example.test")
|
|
|
|
before := countRows(t, r, "staff")
|
|
got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{
|
|
"role": "Event Server", "profile_tier": "Skilled",
|
|
})
|
|
if got.code != http.StatusCreated {
|
|
t.Fatalf("hire: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
|
|
data, _ := got.body["data"].(map[string]any)
|
|
application, _ := data["application"].(map[string]any)
|
|
staff, _ := data["staff"].(map[string]any)
|
|
if application == nil || staff == nil {
|
|
t.Fatalf("hire response is missing application or staff: %v", got.body)
|
|
}
|
|
|
|
if application["status"] != "hired" {
|
|
t.Errorf("application.status = %v, want hired", application["status"])
|
|
}
|
|
if staff["name"] != "Hire Me" {
|
|
t.Errorf("staff.name = %v, want the applicant's name", staff["name"])
|
|
}
|
|
if staff["email"] != "hire-me@example.test" {
|
|
t.Errorf("staff.email = %v, want the application's email", staff["email"])
|
|
}
|
|
// The caller's overrides win over the derived defaults.
|
|
if staff["role"] != "Event Server" {
|
|
t.Errorf("staff.role = %v, want the supplied role", staff["role"])
|
|
}
|
|
if staff["profile_tier"] != "Skilled" {
|
|
t.Errorf("staff.profile_tier = %v, want the supplied tier", staff["profile_tier"])
|
|
}
|
|
// ai_score is carried across from the application. It is an `int` column,
|
|
// so it arrives from pgx as int32 — the case repo.bindValue did not handle
|
|
// until this endpoint existed to read a record and write it elsewhere.
|
|
if score, ok := staff["ai_score"].(float64); !ok || int(score) != 77 {
|
|
t.Errorf("staff.ai_score = %v, want 77 carried from the application", staff["ai_score"])
|
|
}
|
|
if staff["application_id"] != app {
|
|
t.Errorf("staff.application_id = %v, want %s", staff["application_id"], app)
|
|
}
|
|
if after := countRows(t, r, "staff"); after != before+1 {
|
|
t.Errorf("staff rows: %d -> %d, want exactly one more", before, after)
|
|
}
|
|
}
|
|
|
|
// Hiring the same application twice would create a second employment record for
|
|
// one person, so the second attempt is a conflict rather than a repeat.
|
|
func TestHireIsNotRepeatable(t *testing.T) {
|
|
r := newRBAC(t)
|
|
app := applicationFor(t, r, r.activePosting, "Twice", "twice@example.test")
|
|
|
|
if got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{}); got.code != http.StatusCreated {
|
|
t.Fatalf("first hire: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
|
|
before := countRows(t, r, "staff")
|
|
got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{})
|
|
if got.code != http.StatusConflict {
|
|
t.Fatalf("second hire: got %d, want 409 (%v)", got.code, got.body)
|
|
}
|
|
if after := countRows(t, r, "staff"); after != before {
|
|
t.Errorf("a refused hire still wrote a staff row: %d -> %d", before, after)
|
|
}
|
|
}
|
|
|
|
// The whole point of the endpoint: the two writes succeed together or not at
|
|
// all. A staff insert that violates a constraint must leave the application
|
|
// untouched, not merely report an error.
|
|
func TestHireRollsBackTheApplicationWhenStaffFails(t *testing.T) {
|
|
r := newRBAC(t)
|
|
app := applicationFor(t, r, r.activePosting, "Rollback", "rollback@example.test")
|
|
|
|
staffBefore := countRows(t, r, "staff")
|
|
|
|
// profile_tier is a native enum; a value outside it fails the staff INSERT
|
|
// after the application UPDATE has already been issued in this transaction.
|
|
got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{
|
|
"profile_tier": "NotARealTier",
|
|
})
|
|
if got.code == http.StatusCreated {
|
|
t.Fatalf("an invalid profile_tier was accepted: %v", got.body)
|
|
}
|
|
|
|
if after := countRows(t, r, "staff"); after != staffBefore {
|
|
t.Errorf("staff rows changed despite a failed hire: %d -> %d", staffBefore, after)
|
|
}
|
|
|
|
// The decisive assertion: the application must NOT be hired.
|
|
reread := r.as(r.admin, "GET", "/api/v1/job-applications?limit=500", nil)
|
|
if reread.code != http.StatusOK {
|
|
t.Fatalf("re-read applications: %d", reread.code)
|
|
}
|
|
for _, raw := range reread.body["data"].([]any) {
|
|
rec := raw.(map[string]any)
|
|
if rec["id"] == app && rec["status"] == "hired" {
|
|
t.Fatal("the application was left hired after the staff insert failed — " +
|
|
"the two writes are not in one transaction")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hiring is an operator action. A talent user must not be able to hire anyone,
|
|
// including themselves.
|
|
func TestHireIsRefusedToTalent(t *testing.T) {
|
|
r := newRBAC(t)
|
|
app := applicationFor(t, r, r.activePosting, "Self", r.talA.email)
|
|
|
|
before := countRows(t, r, "staff")
|
|
got := r.as(r.talA, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{})
|
|
if got.code != http.StatusForbidden {
|
|
t.Fatalf("talent hire: got %d, want 403 (%v)", got.code, got.body)
|
|
}
|
|
if after := countRows(t, r, "staff"); after != before {
|
|
t.Errorf("a refused hire still wrote a staff row: %d -> %d", before, after)
|
|
}
|
|
}
|
|
|
|
func TestHireRejectsUnknownApplication(t *testing.T) {
|
|
r := newRBAC(t)
|
|
got := r.as(r.admin, "POST",
|
|
"/api/v1/job-applications/00000000-0000-0000-0000-000000000000/hire", map[string]any{})
|
|
if got.code != http.StatusNotFound {
|
|
t.Fatalf("hire unknown application: got %d, want 404 (%v)", got.code, got.body)
|
|
}
|
|
}
|
|
|
|
// Another organization's application is absent, not forbidden — the same 404 a
|
|
// nonexistent id gets, so existence does not leak across tenants.
|
|
func TestHireCannotReachAnotherOrganization(t *testing.T) {
|
|
r := newRBAC(t)
|
|
app := applicationFor(t, r, r.activePosting, "Ours", "ours@example.test")
|
|
|
|
got := r.as(r.outsider, "POST", "/api/v1/job-applications/"+app+"/hire", map[string]any{})
|
|
if got.code != http.StatusNotFound {
|
|
t.Fatalf("cross-tenant hire: got %d, want 404 (%v)", got.code, got.body)
|
|
}
|
|
}
|
|
|
|
/* ── Assign ─────────────────────────────────────────────────────────────── */
|
|
|
|
func TestAssignPlacesWorkersAndUpdatesApplications(t *testing.T) {
|
|
r := newRBAC(t)
|
|
a1 := applicationFor(t, r, r.activePosting, "Worker One", "w1@example.test")
|
|
a2 := applicationFor(t, r, r.activePosting, "Worker Two", "w2@example.test")
|
|
|
|
before := countRows(t, r, "assignments")
|
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments", map[string]any{
|
|
"workers": []map[string]any{
|
|
{"worker_email": "w1@example.test", "worker_name": "Worker One",
|
|
"starts_at": "2026-09-01T09:00:00Z", "application_id": a1, "match_score": 91},
|
|
{"worker_email": "w2@example.test", "worker_name": "Worker Two",
|
|
"starts_at": "2026-09-01T09:00:00Z", "application_id": a2},
|
|
},
|
|
})
|
|
if got.code != http.StatusCreated {
|
|
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
|
|
data, _ := got.body["data"].(map[string]any)
|
|
if count, ok := data["count"].(float64); !ok || int(count) != 2 {
|
|
t.Errorf("count = %v, want 2", data["count"])
|
|
}
|
|
if after := countRows(t, r, "assignments"); after != before+2 {
|
|
t.Errorf("assignment rows: %d -> %d, want two more", before, after)
|
|
}
|
|
|
|
// Both applications must now read as assigned.
|
|
list := r.as(r.admin, "GET", "/api/v1/job-applications?status=assigned", nil)
|
|
assigned := map[string]bool{}
|
|
for _, raw := range list.body["data"].([]any) {
|
|
assigned[raw.(map[string]any)["id"].(string)] = true
|
|
}
|
|
if !assigned[a1] || !assigned[a2] {
|
|
t.Errorf("applications were not moved to assigned: a1=%v a2=%v", assigned[a1], assigned[a2])
|
|
}
|
|
}
|
|
|
|
// A worker with no application is legitimate — that is what the talent pool is
|
|
// for — and must not be invented one.
|
|
func TestAssignAcceptsWorkerWithoutApplication(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": "pool@example.test", "worker_name": "Pool Worker",
|
|
"starts_at": "2026-09-01T09:00:00Z"},
|
|
},
|
|
})
|
|
if got.code != http.StatusCreated {
|
|
t.Fatalf("assign without application: got %d, want 201 (%v)", got.code, got.body)
|
|
}
|
|
}
|
|
|
|
// The batch is all-or-nothing. A bad reference on the SECOND worker must undo
|
|
// the first worker's assignment, not leave it stranded.
|
|
func TestAssignRollsBackTheWholeBatch(t *testing.T) {
|
|
r := newRBAC(t)
|
|
before := countRows(t, r, "assignments")
|
|
|
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments", map[string]any{
|
|
"workers": []map[string]any{
|
|
{"worker_email": "first@example.test", "worker_name": "First",
|
|
"starts_at": "2026-09-01T09:00:00Z"},
|
|
{"worker_email": "second@example.test", "worker_name": "Second",
|
|
"starts_at": "2026-09-01T09:00:00Z",
|
|
"application_id": "00000000-0000-0000-0000-000000000000"},
|
|
},
|
|
})
|
|
if got.code == http.StatusCreated {
|
|
t.Fatalf("a batch naming a nonexistent application was accepted: %v", got.body)
|
|
}
|
|
if after := countRows(t, r, "assignments"); after != before {
|
|
t.Fatalf("the first worker survived the second's failure: %d -> %d — "+
|
|
"the batch is not one transaction", before, after)
|
|
}
|
|
}
|
|
|
|
func TestAssignIsRefusedToTalent(t *testing.T) {
|
|
r := newRBAC(t)
|
|
before := countRows(t, r, "assignments")
|
|
|
|
got := r.as(r.talA, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments", map[string]any{
|
|
"workers": []map[string]any{
|
|
{"worker_email": r.talA.email, "worker_name": "Self",
|
|
"starts_at": "2026-09-01T09:00:00Z"},
|
|
},
|
|
})
|
|
if got.code != http.StatusForbidden {
|
|
t.Fatalf("talent assign: got %d, want 403 (%v)", got.code, got.body)
|
|
}
|
|
if after := countRows(t, r, "assignments"); after != before {
|
|
t.Errorf("a refused assign still wrote a row: %d -> %d", before, after)
|
|
}
|
|
}
|
|
|
|
func TestAssignValidatesTheBatchBeforeWriting(t *testing.T) {
|
|
r := newRBAC(t)
|
|
before := countRows(t, r, "assignments")
|
|
|
|
cases := []struct {
|
|
name string
|
|
body map[string]any
|
|
}{
|
|
{"no workers", map[string]any{"workers": []map[string]any{}}},
|
|
{"missing email", map[string]any{"workers": []map[string]any{
|
|
{"worker_name": "No Email", "starts_at": "2026-09-01T09:00:00Z"}}}},
|
|
{"missing starts_at", map[string]any{"workers": []map[string]any{
|
|
{"worker_email": "x@example.test", "worker_name": "No Start"}}}},
|
|
{"malformed application_id", map[string]any{"workers": []map[string]any{
|
|
{"worker_email": "x@example.test", "starts_at": "2026-09-01T09:00:00Z",
|
|
"application_id": "not-a-uuid"}}}},
|
|
}
|
|
for _, tc := range cases {
|
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments", tc.body)
|
|
if got.code != http.StatusUnprocessableEntity {
|
|
t.Errorf("%s: got %d, want 422 (%v)", tc.name, got.code, got.body)
|
|
}
|
|
}
|
|
if after := countRows(t, r, "assignments"); after != before {
|
|
t.Errorf("a rejected batch wrote rows: %d -> %d", before, after)
|
|
}
|
|
}
|
|
|
|
func TestAssignRejectsUnknownPosting(t *testing.T) {
|
|
r := newRBAC(t)
|
|
got := r.as(r.admin, "POST",
|
|
"/api/v1/job-postings/00000000-0000-0000-0000-000000000000/assignments", map[string]any{
|
|
"workers": []map[string]any{
|
|
{"worker_email": "x@example.test", "starts_at": "2026-09-01T09:00:00Z"},
|
|
},
|
|
})
|
|
if got.code != http.StatusNotFound {
|
|
t.Fatalf("assign to unknown posting: got %d, want 404 (%v)", got.code, got.body)
|
|
}
|
|
}
|
|
|
|
// Both endpoints are behind the session like everything else.
|
|
func TestWorkflowEndpointsRequireASession(t *testing.T) {
|
|
r := newRBAC(t)
|
|
for _, path := range []string{
|
|
"/api/v1/job-applications/00000000-0000-0000-0000-000000000000/hire",
|
|
"/api/v1/job-postings/00000000-0000-0000-0000-000000000000/assignments",
|
|
} {
|
|
if got := r.doAnon("POST", path, map[string]any{}); got.code != http.StatusUnauthorized {
|
|
t.Errorf("%s unauthenticated: got %d, want 401", path, got.code)
|
|
}
|
|
}
|
|
}
|