| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | // writeConfig plants a .carlos/config under dir, the way a checked-in |
| 14 | // project file or `carlos auth default` would. |
| 15 | func writeConfig(t *testing.T, dir, body string) string { |
| 16 | t.Helper() |
| 17 | path := filepath.Join(dir, ".carlos", "config") |
| 18 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 19 | t.Fatal(err) |
| 20 | } |
| 21 | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | return path |
| 25 | } |
| 26 | |
| 27 | // useIsolatedConfig isolates an in-process test the way carlosTestEnv |
| 28 | // isolates a subprocess (e2e_test.go): CARLOS_CONFIG is pointed at a path |
| 29 | // inside a fresh t.TempDir() where no config exists, which makes walkConfig |
| 30 | // and configDefault skip discovery entirely — no cwd walk, no global |
| 31 | // fallback — so a developer's real ~/.carlos/config can never answer for a |
| 32 | // test. HOME is also set to a separate scratch dir (some paths, like |
| 33 | // credentialsPath's fallback, still derive from it), and CARLOS_CONSOLE and |
| 34 | // CARLOS_CREDENTIALS are cleared so neither leaks in from the environment |
| 35 | // running the test binary. |
| 36 | // |
| 37 | // This is deliberately an env override rather than a chdir: t.Chdir forbids |
| 38 | // t.Parallel(), and CARLOS_CONFIG makes the cwd walk irrelevant for any test |
| 39 | // that isn't exercising discovery itself. TestConfigDefaultWalk is the one |
| 40 | // test that genuinely needs the walk, so it builds its own tree with |
| 41 | // t.Chdir instead of calling this helper. |
| 42 | // |
| 43 | // Duplicated wherever package main_test needs the same isolation: this file |
| 44 | // is package main, e2e_test.go is an external test package, and the two |
| 45 | // cannot share an unexported helper across the package boundary. |
| 46 | func useIsolatedConfig(t *testing.T) { |
| 47 | t.Helper() |
| 48 | t.Setenv("HOME", t.TempDir()) |
| 49 | t.Setenv("CARLOS_CONFIG", filepath.Join(t.TempDir(), "config")) |
| 50 | t.Setenv("CARLOS_CONSOLE", "") |
| 51 | t.Setenv("CARLOS_CREDENTIALS", "") |
| 52 | t.Setenv("CARLOS_DESTINATION", "") |
| 53 | } |
| 54 | |
| 55 | func TestLoadConfigFile(t *testing.T) { |
| 56 | useTempCredentials(t) |
| 57 | dir := t.TempDir() |
| 58 | |
| 59 | // Missing file: no default at this level, not an error. |
| 60 | if c, err := loadConfigFile(filepath.Join(dir, ".carlos", "config")); err != nil || c != "" { |
| 61 | t.Fatalf("missing config = %q, %v; want empty, nil", c, err) |
| 62 | } |
| 63 | |
| 64 | // A well-formed file: the console comes back normalized. |
| 65 | p := writeConfig(t, dir, `{"console": "https://tito.example/"}`+"\n") |
| 66 | if c, err := loadConfigFile(p); err != nil || c != "https://tito.example" { |
| 67 | t.Fatalf("loadConfigFile = %q, %v; want the normalized console", c, err) |
| 68 | } |
| 69 | |
| 70 | // Unknown fields are ignored (additive evolution), and a file WITHOUT |
| 71 | // a console field is "no default at this level", not an error. |
| 72 | os.WriteFile(p, []byte(`{"future": true}`), 0o644) |
| 73 | if c, err := loadConfigFile(p); err != nil || c != "" { |
| 74 | t.Fatalf("config without console = %q, %v; want empty, nil", c, err) |
| 75 | } |
| 76 | |
| 77 | // Malformed JSON is an error naming the file — silently skipping a |
| 78 | // typo'd config would ship to the wrong console. |
| 79 | os.WriteFile(p, []byte(`{`), 0o644) |
| 80 | if _, err := loadConfigFile(p); err == nil || !strings.Contains(err.Error(), p) { |
| 81 | t.Fatalf("malformed config = %v, want an error naming %s", err, p) |
| 82 | } |
| 83 | |
| 84 | // A console normalizeConsole rejects is an error too, same reasoning. |
| 85 | os.WriteFile(p, []byte(`{"console": "ftp://nope"}`), 0o644) |
| 86 | if _, err := loadConfigFile(p); err == nil || !strings.Contains(err.Error(), p) { |
| 87 | t.Fatalf("bad-URL config = %v, want an error naming %s", err, p) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // TestConfigDefaultWalk exercises discovery itself — the cwd walk AND the |
| 92 | // global fallback — so it cannot use useIsolatedConfig's CARLOS_CONFIG |
| 93 | // override, which disables discovery entirely. Instead it owns one temp |
| 94 | // tree for the whole test: HOME lives at the tree's root, and the working |
| 95 | // directory is a descendant of that same root, so both the cwd walk and |
| 96 | // the global fallback resolve inside the tree this test controls rather |
| 97 | // than a developer's real ~/.carlos/config. |
| 98 | func TestConfigDefaultWalk(t *testing.T) { |
| 99 | home := t.TempDir() |
| 100 | t.Setenv("HOME", home) |
| 101 | t.Setenv("CARLOS_CONSOLE", "") |
| 102 | t.Setenv("CARLOS_CREDENTIALS", filepath.Join(home, "credentials")) |
| 103 | root := filepath.Join(home, "project") |
| 104 | if err := os.MkdirAll(root, 0o755); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | t.Chdir(root) |
| 108 | |
| 109 | // Nothing anywhere: no default, no error. |
| 110 | if c, src, err := configDefault(); err != nil || c != "" || src != "" { |
| 111 | t.Fatalf("bare configDefault = %q %q %v, want empty", c, src, err) |
| 112 | } |
| 113 | |
| 114 | // Only the global: the global wins, and the source names the file. |
| 115 | globalPath := writeConfig(t, home, `{"console": "https://global.example"}`) |
| 116 | c, src, err := configDefault() |
| 117 | if err != nil || c != "https://global.example" || src != globalPath { |
| 118 | t.Fatalf("global-only = %q %q %v", c, src, err) |
| 119 | } |
| 120 | |
| 121 | // A project config anywhere up the cwd walk beats the global, and the |
| 122 | // NEAREST project config beats a farther one. |
| 123 | writeConfig(t, root, `{"console": "https://outer.example"}`) |
| 124 | nested := filepath.Join(root, "cmd", "deep") |
| 125 | if err := os.MkdirAll(nested, 0o755); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | t.Chdir(nested) |
| 129 | if c, _, err = configDefault(); err != nil || c != "https://outer.example" { |
| 130 | t.Fatalf("walk from nested = %q %v, want the ancestor's config", c, err) |
| 131 | } |
| 132 | nearest := writeConfig(t, nested, `{"console": "https://inner.example"}`) |
| 133 | if c, src, err = configDefault(); err != nil || c != "https://inner.example" || src != nearest { |
| 134 | t.Fatalf("nearest = %q %q %v", c, src, err) |
| 135 | } |
| 136 | |
| 137 | // A level whose config has no console does not stop the walk. |
| 138 | os.WriteFile(nearest, []byte(`{"future": true}`), 0o644) |
| 139 | if c, _, err = configDefault(); err != nil || c != "https://outer.example" { |
| 140 | t.Fatalf("empty-console level = %q %v, want the walk to continue", c, err) |
| 141 | } |
| 142 | |
| 143 | // A malformed config mid-walk is an error, not a skip. |
| 144 | os.WriteFile(nearest, []byte(`not json`), 0o644) |
| 145 | if _, _, err = configDefault(); err == nil { |
| 146 | t.Fatal("malformed project config must be an error, not a skip") |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | func TestSaveConfigFile(t *testing.T) { |
| 151 | useTempCredentials(t) |
| 152 | dir := t.TempDir() |
| 153 | path := filepath.Join(dir, ".carlos", "config") |
| 154 | |
| 155 | // The URL is validated and normalized on WRITE, so a bad default is |
| 156 | // caught when set, not at first use. |
| 157 | if err := saveConfigFile(path, "ftp://nope", 0o755); err == nil { |
| 158 | t.Fatal("saving an invalid console must fail") |
| 159 | } |
| 160 | if err := saveConfigFile(path, "https://tito.example/", 0o755); err != nil { |
| 161 | t.Fatal(err) |
| 162 | } |
| 163 | if c, err := loadConfigFile(path); err != nil || c != "https://tito.example" { |
| 164 | t.Fatalf("round-trip = %q, %v", c, err) |
| 165 | } |
| 166 | // Not a secret: world-readable is fine and expected in a repo. |
| 167 | fi, err := os.Stat(path) |
| 168 | if err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | if fi.Mode().Perm() != 0o644 { |
| 172 | t.Fatalf("config mode = %v, want 0644", fi.Mode().Perm()) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func TestPickHonorsConfigDefault(t *testing.T) { |
| 177 | useTempCredentials(t) |
| 178 | home := os.Getenv("HOME") |
| 179 | cwd, err := os.Getwd() |
| 180 | if err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | now := time.Now().UTC() |
| 184 | f, _ := loadCredentials() |
| 185 | f.put(credential{Console: "https://flagship.example", Address: "p@keymail.dev", |
| 186 | Token: "t-flag", ExpiresAt: now.Add(time.Hour)}) |
| 187 | f.put(credential{Console: "https://tito.example", Address: "p@keymail.dev", |
| 188 | Token: "t-tito", ExpiresAt: now.Add(time.Hour)}) |
| 189 | |
| 190 | // Two credentials, no default: today's refusal — and it now also says |
| 191 | // how to stop having to choose. |
| 192 | if _, err := f.pick(""); err == nil || !strings.Contains(err.Error(), "carlos auth default") { |
| 193 | t.Fatalf("multi-credential refusal = %v, want the set-a-default hint", err) |
| 194 | } |
| 195 | |
| 196 | // A global default resolves the ambiguity. |
| 197 | writeConfig(t, home, `{"console": "https://tito.example"}`) |
| 198 | c, err := f.pick("") |
| 199 | if err != nil || c.Token != "t-tito" { |
| 200 | t.Fatalf("pick with global default = %+v %v, want the tito credential", c, err) |
| 201 | } |
| 202 | |
| 203 | // A project default overrides the global. |
| 204 | writeConfig(t, cwd, `{"console": "https://flagship.example"}`) |
| 205 | if c, err = f.pick(""); err != nil || c.Token != "t-flag" { |
| 206 | t.Fatalf("pick with project default = %+v %v, want the flagship credential", c, err) |
| 207 | } |
| 208 | |
| 209 | // CARLOS_CONSOLE still beats any config. |
| 210 | t.Setenv("CARLOS_CONSOLE", "https://tito.example") |
| 211 | if c, err = f.pick(""); err != nil || c.Token != "t-tito" { |
| 212 | t.Fatalf("env over config = %+v %v", c, err) |
| 213 | } |
| 214 | t.Setenv("CARLOS_CONSOLE", "") |
| 215 | |
| 216 | // An explicit flag still beats everything. |
| 217 | if c, err = f.pick("https://tito.example"); err != nil || c.Token != "t-tito" { |
| 218 | t.Fatalf("flag over config = %+v %v", c, err) |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | func TestConfigDefaultIsAuthoritative(t *testing.T) { |
| 223 | useTempCredentials(t) |
| 224 | cwd, err := os.Getwd() |
| 225 | if err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | // The titogo case: the checkout is pinned to a stack this machine holds |
| 229 | // no credential for, and the only credential is for a DIFFERENT console. |
| 230 | // Falling back to it would quietly ship the project to the wrong |
| 231 | // deployment — the exact mistake the default exists to prevent. |
| 232 | f, _ := loadCredentials() |
| 233 | f.put(credential{Console: "https://flagship.example", Address: "p@keymail.dev", |
| 234 | Token: "t-flag", ExpiresAt: time.Now().Add(time.Hour)}) |
| 235 | src := writeConfig(t, cwd, `{"console": "https://tito.example"}`) |
| 236 | |
| 237 | _, err = f.pick("") |
| 238 | if err == nil { |
| 239 | t.Fatal("a set default with no credential must refuse, not fall back") |
| 240 | } |
| 241 | for _, want := range []string{"https://tito.example", src} { |
| 242 | if !strings.Contains(err.Error(), want) { |
| 243 | t.Fatalf("authoritative refusal %q does not name %q", err, want) |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | // A malformed config is an error even though a usable credential exists. |
| 248 | os.WriteFile(src, []byte(`{`), 0o644) |
| 249 | if _, err := f.pick(""); err == nil { |
| 250 | t.Fatal("malformed config must be an error, not a skip to the credential") |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // The login chain: flag → CARLOS_CONSOLE → project config → global config → |
| 255 | // the flagship. Login is the one verb with a built-in landing place; there |
| 256 | // may be no credential yet, and the flagship is the product answer. |
| 257 | func TestLoginConsoleChain(t *testing.T) { |
| 258 | useTempCredentials(t) |
| 259 | home := os.Getenv("HOME") |
| 260 | cwd, err := os.Getwd() |
| 261 | if err != nil { |
| 262 | t.Fatal(err) |
| 263 | } |
| 264 | |
| 265 | if got, err := loginConsole(""); err != nil || got != defaultConsole { |
| 266 | t.Fatalf("bare loginConsole = %q %v, want the flagship", got, err) |
| 267 | } |
| 268 | writeConfig(t, home, `{"console": "https://global.example"}`) |
| 269 | if got, err := loginConsole(""); err != nil || got != "https://global.example" { |
| 270 | t.Fatalf("global config = %q %v", got, err) |
| 271 | } |
| 272 | writeConfig(t, cwd, `{"console": "https://project.example"}`) |
| 273 | if got, err := loginConsole(""); err != nil || got != "https://project.example" { |
| 274 | t.Fatalf("project config = %q %v", got, err) |
| 275 | } |
| 276 | t.Setenv("CARLOS_CONSOLE", "https://env.example") |
| 277 | if got, err := loginConsole(""); err != nil || got != "https://env.example" { |
| 278 | t.Fatalf("env = %q %v", got, err) |
| 279 | } |
| 280 | if got, err := loginConsole("https://flag.example"); err != nil || got != "https://flag.example" { |
| 281 | t.Fatalf("flag = %q %v", got, err) |
| 282 | } |
| 283 | // A malformed config fails the login rather than silently logging in |
| 284 | // to the flagship. |
| 285 | t.Setenv("CARLOS_CONSOLE", "") |
| 286 | os.WriteFile(filepath.Join(cwd, ".carlos", "config"), []byte(`{`), 0o644) |
| 287 | if _, err := loginConsole(""); err == nil { |
| 288 | t.Fatal("malformed config must fail the login, not skip to the flagship") |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | // End to end: with a configured default and no -console at all, login goes |
| 293 | // where the config points. |
| 294 | func TestAuthLoginUsesConfiguredDefault(t *testing.T) { |
| 295 | useTempCredentials(t) |
| 296 | fc := newFakeConsole(t) |
| 297 | writeConfig(t, os.Getenv("HOME"), `{"console": "`+fc.ts.URL+`"}`) |
| 298 | var out bytes.Buffer |
| 299 | if err := authLogin(&out, nil); err != nil { |
| 300 | t.Fatalf("authLogin via config default: %v\n%s", err, out.String()) |
| 301 | } |
| 302 | f, _ := loadCredentials() |
| 303 | if c := f.find(fc.ts.URL); c == nil || c.Token != "cli-token" { |
| 304 | t.Fatalf("credential stored = %+v, want one for the configured console", c) |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | func TestAuthDefaultVerb(t *testing.T) { |
| 309 | useTempCredentials(t) |
| 310 | cwd, err := os.Getwd() |
| 311 | if err != nil { |
| 312 | t.Fatal(err) |
| 313 | } |
| 314 | var out bytes.Buffer |
| 315 | |
| 316 | // Nothing configured: the report explains the built-in behavior rather |
| 317 | // than printing an empty string. |
| 318 | if err := authDefault(&out, nil); err != nil { |
| 319 | t.Fatal(err) |
| 320 | } |
| 321 | for _, want := range []string{"no default", defaultConsole, "carlos auth default"} { |
| 322 | if !strings.Contains(out.String(), want) { |
| 323 | t.Fatalf("bare report missing %q:\n%s", want, out.String()) |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | // Set the global; the value is normalized on write and read back with |
| 328 | // its source. |
| 329 | out.Reset() |
| 330 | if err := authDefault(&out, []string{"https://global.example/"}); err != nil { |
| 331 | t.Fatal(err) |
| 332 | } |
| 333 | out.Reset() |
| 334 | if err := authDefault(&out, nil); err != nil { |
| 335 | t.Fatal(err) |
| 336 | } |
| 337 | if !strings.Contains(out.String(), "https://global.example") || |
| 338 | !strings.Contains(out.String(), filepath.Join(".carlos", "config")) { |
| 339 | t.Fatalf("report after set:\n%s", out.String()) |
| 340 | } |
| 341 | |
| 342 | // -project writes ./.carlos/config, which then overrides the global. |
| 343 | out.Reset() |
| 344 | if err := authDefault(&out, []string{"-project", "https://project.example"}); err != nil { |
| 345 | t.Fatal(err) |
| 346 | } |
| 347 | if _, err := os.Stat(filepath.Join(cwd, ".carlos", "config")); err != nil { |
| 348 | t.Fatalf("--project did not write ./.carlos/config: %v", err) |
| 349 | } |
| 350 | out.Reset() |
| 351 | if err := authDefault(&out, nil); err != nil { |
| 352 | t.Fatal(err) |
| 353 | } |
| 354 | if !strings.Contains(out.String(), "https://project.example") { |
| 355 | t.Fatalf("project default not effective:\n%s", out.String()) |
| 356 | } |
| 357 | |
| 358 | // A bad URL is refused at set time, and -project with nothing to set |
| 359 | // is a usage error, not a silent no-op. |
| 360 | if err := authDefault(&out, []string{"ftp://nope"}); err == nil { |
| 361 | t.Fatal("setting an invalid console must fail") |
| 362 | } |
| 363 | if err := authDefault(&out, []string{"-project"}); err == nil { |
| 364 | t.Fatal("--project with no URL must be a usage error") |
| 365 | } |
| 366 | if err := authDefault(&out, []string{"a", "b"}); err == nil { |
| 367 | t.Fatal("two positional args must be a usage error") |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | func TestConfigAccountWalk(t *testing.T) { |
| 372 | useTempCredentials(t) |
| 373 | home := os.Getenv("HOME") |
| 374 | |
| 375 | // Nothing anywhere: no account default, no error. |
| 376 | if a, src, cons, err := configAccount(); err != nil || a != "" || src != "" || cons != "" { |
| 377 | t.Fatalf("bare configAccount = %q %q %q %v, want empty", a, src, cons, err) |
| 378 | } |
| 379 | |
| 380 | // Only the global, and the file names no console: a pure account |
| 381 | // default, fileConsole empty. |
| 382 | globalPath := writeConfig(t, home, `{"account": "acme"}`) |
| 383 | a, src, cons, err := configAccount() |
| 384 | if err != nil || a != "acme" || src != globalPath || cons != "" { |
| 385 | t.Fatalf("global-only = %q %q %q %v", a, src, cons, err) |
| 386 | } |
| 387 | |
| 388 | // A nearer file with only a console does not stop the ACCOUNT walk. |
| 389 | root := t.TempDir() |
| 390 | writeConfig(t, root, `{"console": "https://outer.example"}`) |
| 391 | nested := filepath.Join(root, "cmd", "deep") |
| 392 | if err := os.MkdirAll(nested, 0o755); err != nil { |
| 393 | t.Fatal(err) |
| 394 | } |
| 395 | t.Chdir(nested) |
| 396 | if a, _, _, err = configAccount(); err != nil || a != "acme" { |
| 397 | t.Fatalf("console-only level = %q %v, want the walk to continue to acme", a, err) |
| 398 | } |
| 399 | |
| 400 | // The nearest account-bearing file wins and reports ITS console — |
| 401 | // normalized, because the pairing rule compares it to a session console. |
| 402 | nearest := writeConfig(t, nested, `{"console": "https://inner.example/", "account": "tools"}`) |
| 403 | a, src, cons, err = configAccount() |
| 404 | if err != nil || a != "tools" || src != nearest || cons != "https://inner.example" { |
| 405 | t.Fatalf("nearest = %q %q %q %v", a, src, cons, err) |
| 406 | } |
| 407 | |
| 408 | // Malformed config mid-walk is an error, not a skip. |
| 409 | os.WriteFile(nearest, []byte(`not json`), 0o644) |
| 410 | if _, _, _, err = configAccount(); err == nil { |
| 411 | t.Fatal("malformed project config must be an error, not a skip") |
| 412 | } |
| 413 | } |
| 414 | |
| 415 | func TestConfigRewritesPreserveUnknownFields(t *testing.T) { |
| 416 | useTempCredentials(t) |
| 417 | dir := t.TempDir() |
| 418 | path := filepath.Join(dir, ".carlos", "config") |
| 419 | |
| 420 | // A fresh saveConfigAccount writes both keys. |
| 421 | if err := saveConfigAccount(path, "https://tito.example/", "acme", 0o755); err != nil { |
| 422 | t.Fatal(err) |
| 423 | } |
| 424 | cfg, err := loadConfig(path) |
| 425 | if err != nil || cfg.Console != "https://tito.example" || cfg.Account != "acme" { |
| 426 | t.Fatalf("fresh save = %+v, %v", cfg, err) |
| 427 | } |
| 428 | |
| 429 | // A file holding a key from the future keeps it across BOTH rewrites — |
| 430 | // the old drop-on-rewrite behavior made every new key lossy. |
| 431 | if err := os.WriteFile(path, []byte(`{"console":"https://tito.example","future_key":42}`), 0o644); err != nil { |
| 432 | t.Fatal(err) |
| 433 | } |
| 434 | if err := saveConfigAccount(path, "https://tito.example", "tools", 0o755); err != nil { |
| 435 | t.Fatal(err) |
| 436 | } |
| 437 | raw, err := os.ReadFile(path) |
| 438 | if err != nil { |
| 439 | t.Fatal(err) |
| 440 | } |
| 441 | var all map[string]any |
| 442 | if err := json.Unmarshal(raw, &all); err != nil { |
| 443 | t.Fatal(err) |
| 444 | } |
| 445 | if all["future_key"] != float64(42) || all["account"] != "tools" { |
| 446 | t.Fatalf("saveConfigAccount dropped fields: %v", all) |
| 447 | } |
| 448 | if err := saveConfigFile(path, "https://other.example/", 0o755); err != nil { |
| 449 | t.Fatal(err) |
| 450 | } |
| 451 | raw, _ = os.ReadFile(path) |
| 452 | all = map[string]any{} |
| 453 | if err := json.Unmarshal(raw, &all); err != nil { |
| 454 | t.Fatal(err) |
| 455 | } |
| 456 | if all["future_key"] != float64(42) || all["account"] != "tools" || all["console"] != "https://other.example" { |
| 457 | t.Fatalf("saveConfigFile dropped fields: %v", all) |
| 458 | } |
| 459 | |
| 460 | // The console is still validated on write, account or not. |
| 461 | if err := saveConfigAccount(path, "ftp://nope", "x", 0o755); err == nil { |
| 462 | t.Fatal("saving an invalid console must fail") |
| 463 | } |
| 464 | } |
| 465 | |