Files
multica/server/internal/middleware/owner_lookup_test.go
LinYushen c968c13c87 feat(auth): support mcn_ Cloud Node PATs verified via Fleet (#3349)
* feat(auth): support mcn_ Cloud Node PATs verified via Fleet

Adds a new token kind, mcn_ (multica cloud node), recognized in both
the regular Auth and DaemonAuth middlewares. mcn_ tokens are minted
and owned by Multica Cloud (not the local personal_access_tokens
table); the server validates them by POSTing to the Fleet's
/api/v1/pat/verify endpoint and uses the returned owner_id as
X-User-ID for downstream handlers.

Cloud is the authoritative owner of token status, so this is a
verifier-only path with no DB fallback:

  * Fleet says valid:false -> 401 (token genuinely bad)
  * Fleet unreachable / 5xx -> 503 (transient, retry)
  * No MULTICA_CLOUD_FLEET_URL configured -> 401 (fail closed)

Verification results are cached in Redis for 60s under
mul:auth:mcn:<sha256> to bound the per-request load on Fleet without
extending the revocation window beyond what the Cloud doc allows.
Negative results are NOT cached, so a freshly minted token doesn't
get locked out by a stale 'token_not_found'.

Reuses MULTICA_CLOUD_FLEET_URL (the same env the cloud-runtime proxy
already uses) so deployments don't need a second config knob.

Tests cover the happy path, every documented invalid reason, 4xx/5xx
mapping, network error, decode error, ctx cancellation, the
fail-closed valid:true-without-owner_id case, trailing-slash URL
normalization, and the Redis cache short-circuit + negative
no-cache contract. Middleware tests pin the four 401/503/200 outcomes
in both Auth and DaemonAuth.

* auth(mcn): require owner_id to map to a real local user; drop X-User-PAT plumbing

Two related changes:

1. Cloud-verified owner_id is now checked against our local users table.
   The Cloud owner_id and our users.id share the same UUID space by
   contract; a missing local user means either the row was deleted
   under an active node or something is forging owner_ids — either
   way, fail closed.

   CloudPATVerifier.Verify takes a new OwnerLookupFunc:
     - returns (true, nil)   -> success, cache + return
     - returns (false, nil)  -> ErrCloudPATInvalid (reason='owner_unknown'),
                                NOT cached (so a freshly-created user
                                doesn't get locked out for a TTL window)
     - returns (_, error)    -> ErrCloudPATUnavailable (transient,
                                middleware emits 503)

   Both Auth and DaemonAuth wire ownerLookupFor(queries), a new shared
   helper that wraps queries.GetUser, mapping pgx.ErrNoRows / unparseable
   UUIDs to (false, nil) and other errors to a real Go error.

2. Removed all X-User-PAT plumbing. Cloud now mints node-scoped mcn_
   PATs itself during /api/v1/nodes (see multica-cloud
   docs/api/node-pat.md) and ships them into the EC2 instance via SSM,
   so multica-api no longer needs to forward the caller's mul_ PAT.
   Propagating a long-lived user PAT into a remote machine widened
   the blast radius of any node compromise; that's gone now.

   Removed:
     - cloud_runtime.go: withUserPAT option, cloudRuntimeUserPAT,
       generateCloudRuntimePAT, revokeGeneratedPAT
     - cloudruntime/Request.UserPAT field + X-User-PAT header
     - X-User-PAT from CORS allowed headers
     - obsolete handler tests:
         TestCreateCloudRuntimeNodeForwardsValidatedPAT
         TestCreateCloudRuntimeNodeRejectsUnownedPAT
         TestCreateCloudRuntimeNodeRejectsExpiredPAT
         TestCreateCloudRuntimeNodeAutoGeneratesPAT
       replaced with TestCreateCloudRuntimeNodeForwardsBody
     - X-User-PAT references in packages/core/api/client.test.ts

Tests:
  * 3 new verifier-level tests (owner_unknown not cached, lookup error
    -> Unavailable, success path is cached for both fleet AND lookup)
  * 5 new owner_lookup_test.go tests (nil queries, existing user,
    missing user, malformed UUID, DB error)
  * 1 new end-to-end DaemonAuth test (cloud says valid, no local user
    -> 401)
  * Existing X-User-PAT TS assertions removed; full vitest run passes.
  * go test ./... and go vet ./... clean on the server module.
2026-05-27 14:52:03 +08:00

153 lines
5.1 KiB
Go

package middleware
import (
"context"
"errors"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// seedOwnerLookupUser inserts a fresh user row and returns its UUID
// as the canonical hyphenated string. Mirrors the lightweight fixture
// pattern used by setupResolverFixture so the lookup helper can be
// exercised against a real DB without dragging in the heavier handler
// fixture.
func seedOwnerLookupUser(t *testing.T, queries *db.Queries) string {
t.Helper()
ctx := context.Background()
stamp := time.Now().UnixNano()
user, err := queries.CreateUser(ctx, db.CreateUserParams{
Name: "owner-lookup",
Email: pgtypeUniqueEmail(stamp),
})
if err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
// Best-effort cleanup; another test parallel run will land
// on a different stamp so a leak here doesn't bleed.
_ = user
})
return uuidToString(user.ID)
}
// pgtypeUniqueEmail builds an email that is guaranteed unique within
// a test run so concurrent tests don't collide on the email UNIQUE
// index. Using nanosecond + a static suffix mirrors the patterns used
// elsewhere in the repo.
func pgtypeUniqueEmail(stamp int64) string {
return time.Unix(0, stamp).UTC().Format("20060102T150405.000000000") + "@owner-lookup.test"
}
// TestOwnerLookupFor_NilQueries pins the contract that a middleware
// constructed without a *db.Queries handle skips the lookup entirely
// (Verify treats a nil OwnerLookupFunc as "no lookup configured").
// This path only exists to support unit tests that wire up the
// verifier without a real database.
func TestOwnerLookupFor_NilQueries(t *testing.T) {
if got := ownerLookupFor(nil); got != nil {
t.Fatalf("ownerLookupFor(nil) must return nil, got %T", got)
}
}
// TestOwnerLookupFor_ExistingUser confirms the happy path: a real
// row in the user table resolves to (true, nil).
func TestOwnerLookupFor_ExistingUser(t *testing.T) {
pool := openPool(t)
defer pool.Close()
queries := db.New(pool)
userID := seedOwnerLookupUser(t, queries)
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, userID)
})
lookup := ownerLookupFor(queries)
exists, err := lookup(context.Background(), userID)
if err != nil {
t.Fatalf("lookup returned error: %v", err)
}
if !exists {
t.Fatalf("expected user to be found")
}
}
// TestOwnerLookupFor_MissingUser confirms that a syntactically valid
// UUID that does not match any row resolves to (false, nil) — the
// signal the verifier uses to emit reason="owner_unknown" and reject
// without caching.
func TestOwnerLookupFor_MissingUser(t *testing.T) {
pool := openPool(t)
defer pool.Close()
queries := db.New(pool)
lookup := ownerLookupFor(queries)
// Random unused UUID — pgx.ErrNoRows territory.
exists, err := lookup(context.Background(), "00000000-0000-0000-0000-0000deadbeef")
if err != nil {
t.Fatalf("missing user must NOT surface as a lookup error, got %v", err)
}
if exists {
t.Fatalf("expected lookup to report user-not-found")
}
}
// TestOwnerLookupFor_MalformedOwnerID confirms that an unparseable
// owner_id is treated as "user not found" (false, nil), not as a
// transient error. Cloud has already vetted the format on its side
// before signing the verify response, so a bad UUID here means
// either a contract violation or a forged response — either way the
// safe answer is to reject the token, same as a missing user.
func TestOwnerLookupFor_MalformedOwnerID(t *testing.T) {
pool := openPool(t)
defer pool.Close()
queries := db.New(pool)
lookup := ownerLookupFor(queries)
exists, err := lookup(context.Background(), "not-a-uuid")
if err != nil {
t.Fatalf("malformed owner_id must NOT surface as a lookup error, got %v", err)
}
if exists {
t.Fatalf("malformed owner_id must report user-not-found")
}
}
// TestOwnerLookupFor_DBError mirrors the production failure mode the
// verifier maps to ErrCloudPATUnavailable: a query error (DB hung up,
// pool empty, etc.). Closing the pool before the call gives us a
// guaranteed real error rather than mocking the queries layer, which
// the rest of the suite doesn't do.
func TestOwnerLookupFor_DBError(t *testing.T) {
pool := openPool(t)
queries := db.New(pool)
pool.Close() // intentional — every subsequent query must fail
lookup := ownerLookupFor(queries)
exists, err := lookup(context.Background(), "00000000-0000-0000-0000-000000000001")
if err == nil {
t.Fatal("expected real DB error to surface, got nil")
}
if exists {
t.Fatal("DB error must not report user-found")
}
// And the error must NOT be classified as ErrNoRows by the
// caller — that's how the verifier distinguishes "user really
// doesn't exist" (401) from "DB is broken right now" (503).
if errors.Is(err, errors.New("no rows")) {
t.Fatalf("DB error must not look like ErrNoRows, got %v", err)
}
}
// uuidToStringForOwnerLookup avoids depending on the existing
// uuidToString helper here in case its signature changes; the
// fixtures only need the canonical hyphenated form.
//
//nolint:unused // referenced via seedOwnerLookupUser indirectly.
func uuidToStringForOwnerLookup(u pgtype.UUID) string {
return uuidToString(u)
}