| 1 | // Package lint holds build-time guards over this repo's own source — |
| 2 | // properties that used to be enforced only by prose in a doc comment, |
| 3 | // promoted to something `go test ./...` (and so scripts/wire-gate.sh's |
| 4 | // "go: vet, test, gofmt" step) cannot pass without. |
| 5 | package lint |
| 6 | |
| 7 | import ( |
| 8 | "go/ast" |
| 9 | "go/parser" |
| 10 | "go/token" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | ) |
| 17 | |
| 18 | // bannedSessionsUserIDPackages and bannedSessionsUserIDFunc identify |
| 19 | // the one call this repo's own code must never make. See |
| 20 | // TestNoSessionsUserID's own doc comment for why. Both module paths: |
| 21 | // native.go keeps one import of the pre-move package |
| 22 | // (TestNoPreMoveRastrillo), and its UserID is the same trap. |
| 23 | var bannedSessionsUserIDPackages = map[string]bool{ |
| 24 | "amadan.net/rastrillo/rastrillo/sessions": true, |
| 25 | "github.com/carlosframework/rastrillo/sessions": true, |
| 26 | } |
| 27 | |
| 28 | const bannedSessionsUserIDFunc = "UserID" |
| 29 | |
| 30 | // repoRoot resolves the module root from this test's own directory — |
| 31 | // internal/lint, two levels below it — the same "../.." relative-path |
| 32 | // convention testing.go's ReadFixture already uses from internal/docs |
| 33 | // (also two levels below the root), which works because `go test` |
| 34 | // always runs with the package's own directory as its working |
| 35 | // directory. |
| 36 | func repoRoot(t *testing.T) string { |
| 37 | t.Helper() |
| 38 | root, err := filepath.Abs(filepath.Join("..", "..")) |
| 39 | if err != nil { |
| 40 | t.Fatalf("lint: resolve repo root: %v", err) |
| 41 | } |
| 42 | if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { |
| 43 | t.Fatalf("lint: %s does not look like the module root (no go.mod): %v", root, err) |
| 44 | } |
| 45 | return root |
| 46 | } |
| 47 | |
| 48 | // skipDir reports whether WalkDir should not descend into a |
| 49 | // directory: version control, and the two large generated/vendored |
| 50 | // trees this repo's own build produces (fixtures/node_modules from |
| 51 | // `npm ci`, core/target from `cargo build`) — neither holds Go source |
| 52 | // this app wrote, and both are large enough that walking them would |
| 53 | // only slow this test down for nothing. |
| 54 | func skipDir(name string) bool { |
| 55 | switch name { |
| 56 | case ".git", "node_modules", "target": |
| 57 | return true |
| 58 | } |
| 59 | return false |
| 60 | } |
| 61 | |
| 62 | // TestNoSessionsUserID is a build-time guard, not a comment: this |
| 63 | // repo's own code must never call sessions.UserID (rastrillo/auth's |
| 64 | // underlying sessions package), because under the magic-link plugin |
| 65 | // this app uses, the session Subject is the verified email address |
| 66 | // itself — sessions.UserID's own contract can only ever return |
| 67 | // (0, false) here, on every request, with no way for that to change |
| 68 | // short of switching auth plugins. A version of this app's own code |
| 69 | // that called it anyway would not error or panic; it would silently |
| 70 | // scope a query to person/user id 0 and behave like an empty result — |
| 71 | // the same failure class rastrillo's own scope.Owned has on a table |
| 72 | // that grows sharing (store.go's own doc comment on `owned`): wrong |
| 73 | // answers that look like "you just have nothing here," not a crash |
| 74 | // that gets noticed. |
| 75 | // |
| 76 | // Four comments (models.go, store.go, handlers.go, testing.go) have |
| 77 | // said "never sessions.UserID" since Task 1/2. A comment is a code |
| 78 | // review that has to succeed every single time a future change is |
| 79 | // made near this code; this test is what makes a violation impossible |
| 80 | // to land instead of merely discouraged. It walks this repo's own Go |
| 81 | // AST rather than grepping for the symbol, deliberately: a text scan |
| 82 | // would have to carve out an exemption for every one of those four |
| 83 | // comments (and grow that exemption list forever), and could never |
| 84 | // tell a real call apart from a comment or a string that merely |
| 85 | // mentions the name. Flagging only actual call expressions — Fun |
| 86 | // resolving, through the file's own import spec, to |
| 87 | // sessions.UserID — needs no exemption list at all and cannot be |
| 88 | // fooled by a comment quoting the symbol, including this test's own. |
| 89 | // |
| 90 | // Credit: adapted from the Sheets session's TestNoSessionsUserID, |
| 91 | // the same guard for the same reason under the same auth plugin. |
| 92 | func TestNoSessionsUserID(t *testing.T) { |
| 93 | root := repoRoot(t) |
| 94 | fset := token.NewFileSet() |
| 95 | |
| 96 | err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { |
| 97 | if err != nil { |
| 98 | return err |
| 99 | } |
| 100 | if d.IsDir() { |
| 101 | if path != root && skipDir(d.Name()) { |
| 102 | return filepath.SkipDir |
| 103 | } |
| 104 | return nil |
| 105 | } |
| 106 | if !strings.HasSuffix(path, ".go") { |
| 107 | return nil |
| 108 | } |
| 109 | |
| 110 | f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) |
| 111 | if err != nil { |
| 112 | return err |
| 113 | } |
| 114 | |
| 115 | // aliases maps this file's own local identifier for the |
| 116 | // banned package to true (an ordinary named or default |
| 117 | // import) — resolved per file, because the same package can |
| 118 | // legally be imported under different local names in |
| 119 | // different files. dotImported is set instead when the file |
| 120 | // dot-imports it, which puts UserID in scope unqualified |
| 121 | // (a bare *ast.Ident call, no selector at all) rather than as |
| 122 | // sessions.UserID — the AST shape a plain "sessions.UserID" |
| 123 | // text search would never even think to look for, and |
| 124 | // exactly the kind of gap a symbol-precise walk closes for |
| 125 | // free. |
| 126 | aliases := map[string]bool{} |
| 127 | dotImported := false |
| 128 | for _, imp := range f.Imports { |
| 129 | importPath, err := strconv.Unquote(imp.Path.Value) |
| 130 | if err != nil || !bannedSessionsUserIDPackages[importPath] { |
| 131 | continue |
| 132 | } |
| 133 | switch { |
| 134 | case imp.Name == nil: |
| 135 | aliases["sessions"] = true // the package's own default name |
| 136 | case imp.Name.Name == ".": |
| 137 | dotImported = true |
| 138 | case imp.Name.Name == "_": |
| 139 | // blank-imported for side effects only; nothing in |
| 140 | // this file can reference it by name at all. |
| 141 | default: |
| 142 | aliases[imp.Name.Name] = true |
| 143 | } |
| 144 | } |
| 145 | if len(aliases) == 0 && !dotImported { |
| 146 | // This file does not import the banned package under any |
| 147 | // name — nothing it writes can possibly call it, however |
| 148 | // often it mentions the name in a comment or a string. |
| 149 | return nil |
| 150 | } |
| 151 | |
| 152 | ast.Inspect(f, func(n ast.Node) bool { |
| 153 | call, ok := n.(*ast.CallExpr) |
| 154 | if !ok { |
| 155 | return true |
| 156 | } |
| 157 | switch fn := call.Fun.(type) { |
| 158 | case *ast.SelectorExpr: |
| 159 | if fn.Sel.Name != bannedSessionsUserIDFunc { |
| 160 | return true |
| 161 | } |
| 162 | id, ok := fn.X.(*ast.Ident) |
| 163 | if !ok || !aliases[id.Name] { |
| 164 | return true |
| 165 | } |
| 166 | case *ast.Ident: |
| 167 | if !dotImported || fn.Name != bannedSessionsUserIDFunc { |
| 168 | return true |
| 169 | } |
| 170 | default: |
| 171 | return true |
| 172 | } |
| 173 | pos := fset.Position(call.Pos()) |
| 174 | rel, relErr := filepath.Rel(root, pos.Filename) |
| 175 | if relErr != nil { |
| 176 | rel = pos.Filename |
| 177 | } |
| 178 | t.Errorf( |
| 179 | "%s:%d: calls sessions.UserID — banned in this app: under the magic-link plugin the session Subject is the verified email address, so this can only ever return (0, false) here; use auth.From(r).Address and Store.EnsurePerson instead (see handlers.go's owner helper)", |
| 180 | rel, pos.Line, |
| 181 | ) |
| 182 | return true |
| 183 | }) |
| 184 | return nil |
| 185 | }) |
| 186 | if err != nil { |
| 187 | t.Fatalf("lint: walk %s: %v", root, err) |
| 188 | } |
| 189 | } |
| 190 | |