package guards import ( "go/ast" "go/parser" "go/token" "strconv" "testing" ) // sessions.UserID is unusable in this app, and unusable in a way that // does not announce itself. // // It parses the current session's Subject as an integer and returns // (0, false) when that fails. rastrillo/auth sets Subject to the // signed-in email address, so the parse fails on EVERY request — UserID // can never legitimately succeed here. rastrillo's own magic-links page // calls this "the most expensive mistake you can make with this plugin". // // That would be harmless if it errored. It returns 0, and the idiom the // signature invites is `uid, _ := sessions.UserID(r)`. Dropping the ok // scopes every subsequent query to user 0: no rows match, so the page // renders as a plausible empty list rather than failing. Same shape as // scope.Owned on a team-shared table (AGENTS.md) — both fail as a // believable answer instead of an error. // // Use sessions.Current(r) and its Subject (the address) instead. // // This walks the AST for CALL EXPRESSIONS rather than grepping for the // string. A textual match also hits comments, so the moment this rule is // explained anywhere in prose the guard needs an exemption — and an // exemption list is a second thing to maintain that quietly grows until // it covers the violation it was meant to catch. Docs hit exactly that // (four warning comments, no call sites) and made the same change. const ( sessionsPkg = "amadan.net/rastrillo/rastrillo/sessions" forbiddenIdent = "UserID" ) func TestNoSessionsUserID(t *testing.T) { root := repoRoot(t) for _, path := range goFiles(t, root) { rel := relPath(t, root, path) fset := token.NewFileSet() f, err := parser.ParseFile(fset, path, nil, 0) if err != nil { t.Fatalf("guards: parse %s: %v", rel, err) } // Local name for the sessions package in THIS file: it may be // aliased, dot-imported, or absent. No import, no violation // possible. // A dot-import puts UserID in the file scope as a bare // identifier, so a SelectorExpr check alone would miss it // entirely. Docs hit exactly that when they took this guard. local, dotImported := "", false for _, imp := range f.Imports { p, err := strconv.Unquote(imp.Path.Value) if err != nil || p != sessionsPkg { continue } local = "sessions" if imp.Name != nil { local = imp.Name.Name } if local == "." { dotImported = true } } if local == "" { continue } ast.Inspect(f, func(n ast.Node) bool { call, ok := n.(*ast.CallExpr) if !ok { return true } switch fn := call.Fun.(type) { case *ast.SelectorExpr: pkg, ok := fn.X.(*ast.Ident) if !ok || pkg.Name != local || fn.Sel.Name != forbiddenIdent { return true } case *ast.Ident: if !dotImported || fn.Name != forbiddenIdent { return true } default: return true } qualifier, use := local+".", local+".Current(r).Subject" if dotImported { qualifier, use = "", "Current(r).Subject" } pos := fset.Position(call.Pos()) t.Errorf("%s:%d: calls %s%s, which returns (0, false) for every "+ "request in this app because auth's Subject is an email address "+ "— a dropped ok scopes the query to user 0 and renders as an "+ "empty list. Use %s.", rel, pos.Line, qualifier, forbiddenIdent, use) return true }) } }