iam: a refused key says WHICH refusal it was
"the entity does not exist" was one sentence for causes that call for opposite actions. A holder whose key was revoked went looking for a deleted org instead of minting a new key, and a tenant admin's forgery attempt — the same-tenant pin firing — was indistinguishable from a typo. The reason is now a value (store.KeyFailure) carried beside the error rather than baked into its text. KeyError unwraps to orm.ErrNotFound, so every existing errors.Is caller keeps working unchanged and unaware. Both doors enumerate honestly: the secret door distinguishes unknown / wrong-door / foreign-user / dangling-user, and the publishable door distinguishes unknown / not-publishable / expired — the trio cloud's own test annotated while having no way to tell them apart. The human `msg` is byte-identical; the reason rides as `code`. Nothing that reads the prose can tell the causes apart, and the caller that reaches it has already passed CapKeyResolve — it can resolve any key to a full principal, so the code discloses nothing it could not obtain. A store fault yields no reason at all, so infrastructure trouble is never reported to a holder as a bad credential. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -281,10 +281,18 @@ type keyUser struct {
|
||||
// its owning principal. The gate is service-only and fail-secure: the caller must be
|
||||
// a confidential app (p.App != "") holding CapKeyResolve — a human, even a
|
||||
// SuperAdmin, is refused, because a capability is held vacuously by non-apps and key
|
||||
// resolution is a machine-identity boundary, never an interactive admin action. An
|
||||
// unknown/unresolvable key answers the same not-exist envelope every other get- verb
|
||||
// uses, so a prober cannot distinguish a missing key from a denied one beyond the
|
||||
// auth refusal.
|
||||
// resolution is a machine-identity boundary, never an interactive admin action.
|
||||
//
|
||||
// THE `msg` STAYS UNIFORM AND THE `code` SAYS WHY. Every unresolvable key answers the
|
||||
// same not-exist sentence every other get- verb uses, so nothing that reads the prose
|
||||
// can tell a missing key from a denied one. The machine-readable reason rides beside
|
||||
// it because THE GATE ABOVE IS THE BOUNDARY, not the vagueness of this sentence: a
|
||||
// caller that reaches this line has already proven it is a confidential app holding
|
||||
// CapKeyResolve, and such a caller can resolve any key it likes to a full principal.
|
||||
// Telling it which refusal occurred discloses nothing it could not already obtain,
|
||||
// and withholding it is what made a revoked key indistinguishable from a deleted org
|
||||
// for every human downstream. There is no anonymous reader of this envelope to
|
||||
// oracle.
|
||||
func resolveUserByAccessKey(c *zip.Ctx, db orm.DB, key string) error {
|
||||
ctx := c.Context()
|
||||
p, ok := authz.From(ctx)
|
||||
@@ -293,7 +301,7 @@ func resolveUserByAccessKey(c *zip.Ctx, db orm.DB, key string) error {
|
||||
}
|
||||
u, err := store.UserByAccessKey(ctx, db, key)
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return httpx.Err(c, "the entity does not exist")
|
||||
return httpx.ErrCode(c, "the entity does not exist", string(store.Reason(err)))
|
||||
}
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
|
||||
@@ -40,6 +40,7 @@ const (
|
||||
type keyEnv struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
@@ -231,3 +232,52 @@ func TestGetUserByAccessKey_EmptyFallsThrough(t *testing.T) {
|
||||
t.Fatalf("empty accessKey did not fall through to owner/name read: status=%d body=%s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal REASON reaches the wire while the human sentence stays uniform.
|
||||
//
|
||||
// "the entity does not exist" is IAM's generic answer, and cloud rendered it verbatim
|
||||
// to users: a holder whose key had been revoked was told their entity was gone and
|
||||
// went looking for a deleted organization instead of minting a new key. The prose is
|
||||
// deliberately unchanged — nothing that reads `msg` can tell the causes apart — and
|
||||
// the machine-readable `code` carries the reason to the confidential app that already
|
||||
// passed CapKeyResolve to get here.
|
||||
func TestGetUserByAccessKey_RefusalCarriesItsReason(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
keyFixtures(t, h)
|
||||
|
||||
for _, tc := range []struct{ name, key, wantCode string }{
|
||||
{"revoked / never minted", "hk-live-NOSUCHKEY", "key_unknown"},
|
||||
{"unknown secret half", "sk-live-NOSUCHKEY", "key_unknown"},
|
||||
{"a publishable key at the SECRET door", projPK, "key_wrong_door"},
|
||||
{"an unrecognized shape", "fw_deadbeef", "key_wrong_door"},
|
||||
} {
|
||||
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+tc.key, resolverApp, svcSecret)
|
||||
var e keyEnv
|
||||
_ = json.Unmarshal([]byte(body), &e)
|
||||
if e.Status != "error" || e.Msg != "the entity does not exist" {
|
||||
t.Fatalf("%s: env=%+v — the human sentence must stay uniform", tc.name, e)
|
||||
}
|
||||
if e.Code != tc.wantCode {
|
||||
t.Errorf("%s: code = %q, want %q", tc.name, e.Code, tc.wantCode)
|
||||
}
|
||||
// The credential must never be echoed back, in any field.
|
||||
if strings.Contains(body, tc.key) {
|
||||
t.Errorf("%s: the refusal echoed the presented key: %s", tc.name, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The AUTH refusal is not a key reason. A caller that fails the CapKeyResolve gate
|
||||
// gets the unauthorized envelope and NO code at all — so a non-cap caller can never
|
||||
// use `code` as an existence oracle for keys it may not resolve.
|
||||
func TestGetUserByAccessKey_NonCapCallerLearnsNoReason(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
keyFixtures(t, h)
|
||||
|
||||
_, body := h.getBasic(t, "/v1/iam/get-user?accessKey="+keyUserHK, otherApp, svcSecret)
|
||||
var e keyEnv
|
||||
_ = json.Unmarshal([]byte(body), &e)
|
||||
if e.Status != "error" || e.Code != "" {
|
||||
t.Fatalf("non-cap caller env=%+v, want an error with NO code", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,11 @@ type resolveResponse struct {
|
||||
// It names an organization and never a person: no path through it can load or
|
||||
// return a user, so a key you put in client code cannot become a way to learn
|
||||
// who anyone is. A key that is expired, secret rather than publishable, or
|
||||
// simply unknown all answer identically, so nothing here can be probed to
|
||||
// discover which keys exist.
|
||||
// simply unknown all answer with the same sentence, and with a `code` saying
|
||||
// which of those it was. Only a confidential service that already proved it may
|
||||
// resolve keys at all ever reads that code — there is no anonymous caller here
|
||||
// to probe for which keys exist — and telling it apart is what lets the holder
|
||||
// be told to re-mint an expired key instead of hunting a configuration error.
|
||||
func resolveKeyHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
@@ -48,8 +51,11 @@ func resolveKeyHandler(db orm.DB) zip.Handler {
|
||||
k, err := store.PublishableKeyByAccessKey(ctx, db, c.Query("accessKey"), time.Now())
|
||||
if err != nil {
|
||||
// Not found, not a pk-, not publishable, expired, or a store error — one
|
||||
// opaque envelope, no oracle (store.PublishableKeyByAccessKey fails closed).
|
||||
return httpx.Err(c, "the entity does not exist")
|
||||
// envelope, and `code` distinguishes them for the confidential app that
|
||||
// already passed CapPublishableResolve above. A store fault yields no
|
||||
// reason at all (store.Reason returns ""), so infrastructure trouble is
|
||||
// never reported to the holder as a bad key.
|
||||
return httpx.ErrCode(c, "the entity does not exist", string(store.Reason(err)))
|
||||
}
|
||||
return httpx.Ok(c, resolveResponse{Org: k.Owner, Scope: k.Scope})
|
||||
}
|
||||
|
||||
@@ -22,11 +22,18 @@ import (
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Data any `json:"data"`
|
||||
Data2 any `json:"data2,omitempty"`
|
||||
Data3 any `json:"data3,omitempty"`
|
||||
// Code is a STABLE machine-readable reason, where the human `msg` is
|
||||
// deliberately generic. `msg` is prose for a person and several distinct causes
|
||||
// legitimately share one sentence; a caller that must BRANCH on the cause — or
|
||||
// tell its own user which of them happened — cannot parse prose. Optional, so
|
||||
// every existing envelope is byte-identical and no SDK changes.
|
||||
Code string `json:"code,omitempty"`
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
Data any `json:"data"`
|
||||
Data2 any `json:"data2,omitempty"`
|
||||
Data3 any `json:"data3,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceToken returns the configured unified service token — the first non-empty
|
||||
@@ -66,7 +73,13 @@ func Ok(c *zip.Ctx, data any, more ...any) error {
|
||||
// Err writes 200 { status:"error", msg } — the SDK contract (branch on status,
|
||||
// not HTTP code).
|
||||
func Err(c *zip.Ctx, msg string) error {
|
||||
return c.JSON(200, Response{Status: "error", Msg: msg})
|
||||
return ErrCode(c, msg, "")
|
||||
}
|
||||
|
||||
// ErrCode is Err carrying a machine-readable reason alongside the human message.
|
||||
// ONE implementation writes the error envelope; Err is this with no reason to give.
|
||||
func ErrCode(c *zip.Ctx, msg, code string) error {
|
||||
return c.JSON(200, Response{Status: "error", Msg: msg, Code: code})
|
||||
}
|
||||
|
||||
// Bearer returns the token from an `Authorization: Bearer <token>` header, or "".
|
||||
|
||||
+97
-15
@@ -4,6 +4,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -37,11 +38,83 @@ import (
|
||||
// read anywhere. Its ONLY resolution is org-only, at the ingest door
|
||||
// (keys.resolve → /v1/iam/resolve-key), and only for a publishable key.
|
||||
|
||||
// UserByAccessKey resolves an opaque API key to the user it authenticates, or
|
||||
// orm.ErrNotFound for an empty/unknown/unrecognized/publishable key. It never returns
|
||||
// a wrong user: each SECRET shape (hk-/sk-) resolves through its own exact-match
|
||||
// lookup, an sk- key whose row names no resolvable user fails closed rather than
|
||||
// guessing one, and a public pk- resolves to nobody at all.
|
||||
// ── why a key did not resolve ────────────────────────────────────────────────
|
||||
//
|
||||
// FAILING CLOSED AND FAILING SILENTLY ARE DIFFERENT THINGS, and this file used to do
|
||||
// both. Every non-resolution collapsed into a bare orm.ErrNotFound, which the compat
|
||||
// handler rendered as "the entity does not exist" — one sentence for causes that call
|
||||
// for opposite actions from the holder. A user whose key was REVOKED went looking for
|
||||
// a deleted organization instead of minting a new key, and a tenant admin's forgery
|
||||
// attempt (the same-tenant pin below) was indistinguishable from a typo.
|
||||
//
|
||||
// The reason is therefore a VALUE, carried beside the error rather than baked into
|
||||
// its text. It changes no decision here — every branch still refuses — so refusing
|
||||
// and explaining stay orthogonal.
|
||||
type KeyFailure string
|
||||
|
||||
const (
|
||||
// KeyWrongDoor: a shape this door does not answer for. A pk- (or anything
|
||||
// unrecognized) at the SECRET door, or a non-pk- at the publishable one. The
|
||||
// credential may be perfectly valid — it was presented at the wrong door.
|
||||
KeyWrongDoor KeyFailure = "key_wrong_door"
|
||||
// KeyUnknown: a well-shaped key that no row bears. Never minted, already
|
||||
// revoked, or — for the legacy hk- population — clobbered when a later mint
|
||||
// overwrote schema.User.AccessKey (see keys.MintUserKey). The holder's cure is
|
||||
// to mint a new key; nothing about their org is wrong.
|
||||
KeyUnknown KeyFailure = "key_unknown"
|
||||
// KeyForeignUser: an sk- row resolved, but it names a user in ANOTHER tenant.
|
||||
// The same-tenant pin (userOwningKey) refused it. This is a SECURITY EVENT, not
|
||||
// a user error, and it must never again look like one.
|
||||
KeyForeignUser KeyFailure = "key_foreign_user"
|
||||
// KeyDanglingUser: an sk- row resolved and named a same-tenant user that does
|
||||
// not exist. A data-integrity fault in the key table, not the holder's doing.
|
||||
KeyDanglingUser KeyFailure = "key_dangling_user"
|
||||
// KeyNotPublishable: a real key addressed by its pk- half whose scope is not
|
||||
// publish. The browser door refuses it precisely because it is a secret.
|
||||
KeyNotPublishable KeyFailure = "key_not_publishable"
|
||||
// KeyExpired: the row exists and is the right scope, but its lifetime has run
|
||||
// out. Only the publishable door can report this — see the note on keyLive.
|
||||
KeyExpired KeyFailure = "key_expired"
|
||||
)
|
||||
|
||||
// KeyError is an orm.ErrNotFound that ALSO says why. It Unwraps to orm.ErrNotFound
|
||||
// so every existing `errors.Is(err, orm.ErrNotFound)` caller keeps working untouched
|
||||
// and unaware: the reason is strictly additive, and a caller that does not care
|
||||
// about it is not made to.
|
||||
type KeyError struct{ Reason KeyFailure }
|
||||
|
||||
func (e *KeyError) Error() string { return "key not resolved: " + string(e.Reason) }
|
||||
func (e *KeyError) Unwrap() error { return orm.ErrNotFound }
|
||||
|
||||
// Reason extracts the failure reason from a key-resolution error. A plain
|
||||
// orm.ErrNotFound (from a path that predates this type) reads as KeyUnknown, and
|
||||
// anything else — a real store fault — reads as "" so a caller never reports an
|
||||
// infrastructure failure as a bad credential.
|
||||
func Reason(err error) KeyFailure {
|
||||
var ke *KeyError
|
||||
if errors.As(err, &ke) {
|
||||
return ke.Reason
|
||||
}
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return KeyUnknown
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func notFound(r KeyFailure) error { return &KeyError{Reason: r} }
|
||||
|
||||
// UserByAccessKey resolves an opaque API key to the user it authenticates, or a
|
||||
// KeyError (an orm.ErrNotFound naming its cause) for an empty/unknown/unrecognized/
|
||||
// publishable key. It never returns a wrong user: each SECRET shape (hk-/sk-)
|
||||
// resolves through its own exact-match lookup, an sk- key whose row names no
|
||||
// resolvable user fails closed rather than guessing one, and a public pk- resolves to
|
||||
// nobody at all.
|
||||
//
|
||||
// NOTE ON EXPIRY: neither secret shape can report KeyExpired, because neither has an
|
||||
// expiry to read. An hk- lives on the User row, which carries no lifetime at all; an
|
||||
// sk- resolves through userOwningKey, which does not consult keyLive. Revocation is
|
||||
// deletion (or clearing the field), so for a secret key "gone" is the only
|
||||
// termination and KeyUnknown is the honest answer.
|
||||
func UserByAccessKey(ctx context.Context, db orm.DB, key string) (*schema.User, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
switch {
|
||||
@@ -52,7 +125,7 @@ func UserByAccessKey(ctx context.Context, db orm.DB, key string) (*schema.User,
|
||||
default:
|
||||
// A pk- publishable half lands here with every other unrecognized value: it
|
||||
// is WRITE-ONLY and never a principal (see the package note above). Fail closed.
|
||||
return nil, orm.ErrNotFound
|
||||
return nil, notFound(KeyWrongDoor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +134,7 @@ func UserByAccessKey(ctx context.Context, db orm.DB, key string) (*schema.User,
|
||||
func userByField(_ context.Context, db orm.DB, field, val string) (*schema.User, error) {
|
||||
u, err := orm.TypedQuery[schema.User](db).Filter(field+"=", val).First()
|
||||
if err == orm.ErrNotFound {
|
||||
return nil, orm.ErrNotFound
|
||||
return nil, notFound(KeyUnknown)
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
@@ -86,23 +159,25 @@ func userByField(_ context.Context, db orm.DB, field, val string) (*schema.User,
|
||||
func userOwningKey(ctx context.Context, db orm.DB, field, val string) (*schema.User, error) {
|
||||
k, err := orm.TypedQuery[schema.Key](db).Filter(field+"=", val).First()
|
||||
if err == orm.ErrNotFound {
|
||||
return nil, orm.ErrNotFound
|
||||
return nil, notFound(KeyUnknown)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner, name := keyUserRef(k)
|
||||
// Same-tenant pin: the resolved user MUST live in the key row's own org. A
|
||||
// "/"-qualified User naming a foreign owner is a forgery attempt — fail closed.
|
||||
// "/"-qualified User naming a foreign owner is a forgery attempt — fail closed,
|
||||
// and say WHICH refusal this was: a cross-tenant reference is an attack signal
|
||||
// and must not read to an operator as a mistyped key.
|
||||
if owner == "" || name == "" || owner != k.Owner {
|
||||
return nil, orm.ErrNotFound
|
||||
return nil, notFound(KeyForeignUser)
|
||||
}
|
||||
u, err := GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u == nil {
|
||||
return nil, orm.ErrNotFound
|
||||
return nil, notFound(KeyDanglingUser)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -138,17 +213,24 @@ func keyUserRef(k *schema.Key) (owner, name string) {
|
||||
func PublishableKeyByAccessKey(ctx context.Context, db orm.DB, key string, now time.Time) (*schema.Key, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if !strings.HasPrefix(key, "pk-") {
|
||||
return nil, orm.ErrNotFound
|
||||
return nil, notFound(KeyWrongDoor)
|
||||
}
|
||||
k, err := orm.TypedQuery[schema.Key](db).Filter("AccessKey=", key).First()
|
||||
if err == orm.ErrNotFound {
|
||||
return nil, orm.ErrNotFound
|
||||
return nil, notFound(KeyUnknown)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if k.Scope != schema.KeyScopePublish || !keyLive(k, now) {
|
||||
return nil, orm.ErrNotFound
|
||||
// Two different refusals, told apart. "Not publishable" means the holder used a
|
||||
// secret key's public half at the ingest door and should present its pk-;
|
||||
// "expired" means the right key simply ran out and must be re-minted. Collapsing
|
||||
// them sent the second holder hunting for a configuration error they did not have.
|
||||
if k.Scope != schema.KeyScopePublish {
|
||||
return nil, notFound(KeyNotPublishable)
|
||||
}
|
||||
if !keyLive(k, now) {
|
||||
return nil, notFound(KeyExpired)
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
@@ -203,3 +203,99 @@ func TestPublishableKeyByAccessKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── the reason, not just the refusal ─────────────────────────────────────────
|
||||
|
||||
// Every refusal still fails closed AND now says which refusal it was. "the entity
|
||||
// does not exist" was one sentence for causes that call for opposite actions: a
|
||||
// revoked key needs re-minting, a pk- at the secret door needs the other door, and a
|
||||
// cross-tenant key row is an ATTACK — none of which the holder or an operator could
|
||||
// tell apart. The reason is additive: errors.Is(err, orm.ErrNotFound) still holds for
|
||||
// every case, so no existing caller changes behavior.
|
||||
func TestUserByAccessKey_ReasonsAreDistinguishable(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
seedKeyUser(t, db, "hanzo", "alice", "alice@hanzo.ai", "hk-live-ALICEKEY")
|
||||
seedKey(t, db, "hanzo", "org-key", "", "pk-live-ORGONLY", "sk-live-ORGONLY")
|
||||
// An attacker's own-org key naming a foreign identity — the same-tenant pin.
|
||||
seedKey(t, db, "attackerOrg", "forge", "victimorg/ceo", "pk-live-FORGE", "sk-live-FORGE")
|
||||
// A key naming a same-tenant user that does not exist — a dangling row.
|
||||
seedKey(t, db, "hanzo", "dangling", "hanzo/ghost", "pk-live-GHOST", "sk-live-GHOST")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
key string
|
||||
want KeyFailure
|
||||
}{
|
||||
{"revoked or never-minted hk", "hk-live-NOSUCH", KeyUnknown},
|
||||
{"unknown sk", "sk-live-NOSUCH", KeyUnknown},
|
||||
{"a pk- at the SECRET door", "pk-live-ORGONLY", KeyWrongDoor},
|
||||
{"an unrecognized shape", "fw_deadbeef", KeyWrongDoor},
|
||||
{"empty", "", KeyWrongDoor},
|
||||
{"cross-tenant key row is a SECURITY event", "sk-live-FORGE", KeyForeignUser},
|
||||
{"user-less key row cannot name a principal", "sk-live-ORGONLY", KeyForeignUser},
|
||||
{"key naming a nonexistent same-tenant user", "sk-live-GHOST", KeyDanglingUser},
|
||||
} {
|
||||
got, err := UserByAccessKey(ctx, db, tc.key)
|
||||
if got != nil {
|
||||
t.Fatalf("%s: resolved %+v, want nil — every case must still fail closed", tc.name, got)
|
||||
}
|
||||
if !errors.Is(err, orm.ErrNotFound) {
|
||||
t.Fatalf("%s: err=%v, want it to still satisfy errors.Is(_, orm.ErrNotFound)", tc.name, err)
|
||||
}
|
||||
if r := Reason(err); r != tc.want {
|
||||
t.Errorf("%s: reason = %q, want %q", tc.name, r, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The publishable door tells its three refusals apart. This is the trio the cloud
|
||||
// fork's own test annotated as "unknown / not publishable / expired" while having no
|
||||
// way to distinguish them — the annotation is now executable.
|
||||
func TestPublishableKeyByAccessKey_ReasonsAreDistinguishable(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
seedPublishKey(t, db, "hanzo", "site-exp", "pk-live-EXPIRED", "2020-01-01T00:00:00Z")
|
||||
seedKey(t, db, "hanzo", "secret", "hanzo/alice", "pk-live-SECRETHALF", "sk-live-x")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
key string
|
||||
want KeyFailure
|
||||
}{
|
||||
{"unknown", "pk-live-NOSUCH", KeyUnknown},
|
||||
{"not publishable", "pk-live-SECRETHALF", KeyNotPublishable},
|
||||
{"expired", "pk-live-EXPIRED", KeyExpired},
|
||||
{"not a pk- at all", "sk-live-x", KeyWrongDoor},
|
||||
} {
|
||||
k, err := PublishableKeyByAccessKey(ctx, db, tc.key, now)
|
||||
if k != nil {
|
||||
t.Fatalf("%s: resolved %+v, want nil", tc.name, k)
|
||||
}
|
||||
if !errors.Is(err, orm.ErrNotFound) {
|
||||
t.Fatalf("%s: err=%v, want errors.Is(_, orm.ErrNotFound)", tc.name, err)
|
||||
}
|
||||
if r := Reason(err); r != tc.want {
|
||||
t.Errorf("%s: reason = %q, want %q", tc.name, r, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A real store fault is NOT a bad credential. Reason yields "" for anything that is
|
||||
// not a not-found, so a caller can never render infrastructure trouble to a user as
|
||||
// "your key is invalid".
|
||||
func TestReason_StoreFaultIsNotAKeyFailure(t *testing.T) {
|
||||
if r := Reason(errors.New("dial tcp: connection refused")); r != "" {
|
||||
t.Fatalf("Reason(store fault) = %q, want \"\"", r)
|
||||
}
|
||||
if r := Reason(nil); r != "" {
|
||||
t.Fatalf("Reason(nil) = %q, want \"\"", r)
|
||||
}
|
||||
// A bare orm.ErrNotFound from a path predating KeyError still reads as unknown.
|
||||
if r := Reason(orm.ErrNotFound); r != KeyUnknown {
|
||||
t.Fatalf("Reason(orm.ErrNotFound) = %q, want %q", r, KeyUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user