/api/visits, /api/cameras, /api/sites, /api/visitors and /api/reports/footfall, all in production, all before today's work. A platform admin is defined by having NO client, and every tenant query scopes on client_id = $1::uuid - so the empty string reaches Postgres as ''::uuid, which is a cast ERROR rather than an empty result. Found by calling them while verifying the new routes, which have the same shape and were failing the same way. tenantOnly is the guard, beside adminOnly and for the opposite audience. Per-query casts would have been the wrong fix twice over: it is a fix the next query forgets, and the next query would then 500 in production exactly as these did. 403, not adminOnly's 404, because the two hide opposite things. A tenant must not learn a platform surface exists. A platform admin already knows the tenant surface does - they are reading its data through /api/admin - so nothing is concealed by pretending otherwise, and the refusal names the route to use instead. "Forbidden" alone sends somebody hunting a permissions problem that does not exist. /api/auth/* stays on plain authed: a session is not a company's data, and signing out or revoking a lost device must keep working for an account with no tenant. The fake could not have caught this either - it compares client ids as strings and is perfectly content with "". The test asserts the contract (403 and a message naming /api/admin) and a third case that matters more than either: an ordinary tenant user still reaches all of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
69 lines
2.4 KiB
Go
69 lines
2.4 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A platform admin has no client, and every tenant query scopes on one. Before
|
|
// this guard the empty string reached Postgres as `client_id = ”::uuid`,
|
|
// which is a cast ERROR and not an empty result - so five live endpoints
|
|
// answered 500 to a signed-in platform admin. Found by calling them, not by a
|
|
// test: the in-memory fake compares strings and is perfectly happy with "".
|
|
func TestATenantRouteRefusesAnAccountWithNoCompany(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedPlatformAdmin(fs)
|
|
sess := login(t, s, "root@loyaly.ai", "admin123")
|
|
|
|
for _, path := range []string{
|
|
"/api/visits", "/api/cameras", "/api/sites", "/api/visitors",
|
|
"/api/reports/footfall", "/api/sales", "/api/dashboard/summary",
|
|
} {
|
|
rec := do(t, s, "GET", path, sess.Token, nil)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Errorf("%s: got %d, want 403 - a platform admin reads a company's "+
|
|
"data through /api/admin, and a 500 here reads as a broken "+
|
|
"server rather than a wrong door", path, rec.Code)
|
|
}
|
|
// The message has to say where to go instead; "forbidden" alone sends
|
|
// somebody hunting a permissions problem that does not exist.
|
|
if !strings.Contains(rec.Body.String(), "/api/admin") {
|
|
t.Errorf("%s: refusal should point at the admin routes: %s",
|
|
path, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// The guard must not lock a platform admin out of their own session, which is
|
|
// not a company's data and is how they sign out or revoke a lost device.
|
|
func TestAPlatformAdminKeepsTheirOwnSessionRoutes(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedPlatformAdmin(fs)
|
|
sess := login(t, s, "root@loyaly.ai", "admin123")
|
|
|
|
for _, path := range []string{"/api/auth/me", "/api/auth/sessions"} {
|
|
if rec := do(t, s, "GET", path, sess.Token, nil); rec.Code != http.StatusOK {
|
|
t.Errorf("%s: got %d, want 200", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// And the ordinary case must be untouched: a tenant user still reaches
|
|
// everything they always did.
|
|
func TestATenantUserIsUnaffectedByTheGuard(t *testing.T) {
|
|
s, fs := newServer(t)
|
|
seedUser(fs)
|
|
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
|
|
|
for _, path := range []string{
|
|
"/api/visits", "/api/cameras", "/api/sites", "/api/visitors",
|
|
"/api/sales", "/api/dashboard/summary",
|
|
} {
|
|
if rec := do(t, s, "GET", path, sess.Token, nil); rec.Code != http.StatusOK {
|
|
t.Errorf("%s: got %d, want 200 for an ordinary tenant account: %s",
|
|
path, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|