// End-to-end acceptance for `carlos logs` (#131/#78): a REAL console.Server // over a DirStore holding shipped log chunks, a device-flow login, then the // real runLogs driven in-process — the member's whole path from "my app is // 503ing" to reading the wake failure, with no box access anywhere. // TestE2EInstancesThroughConsole's shape. package main import ( "context" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/applog" "github.com/carlosframework/platform/internal/console" "github.com/carlosframework/platform/internal/release" ) func TestE2ELogsThroughConsole(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) tmp := t.TempDir() relStore := release.NewDirStore(filepath.Join(tmp, "store")) seed, pubB64, err := release.GenerateKey() if err != nil { t.Fatal(err) } priv, err := release.ParseSigningKey(seed, "e2e") if err != nil { t.Fatal(err) } pub, err := release.ParsePublicKey(pubB64, "e2e") if err != nil { t.Fatal(err) } cst, err := console.Open(filepath.Join(tmp, "console.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { cst.Close() }) var srv *console.Server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.ServeHTTP(w, r) })) t.Cleanup(ts.Close) srv, err = console.NewServer(console.Config{ Origin: ts.URL, Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin Registry: filepath.Join(tmp, "registry.db"), Releases: relStore, ReleasePub: pub, Key: priv, AppScheme: "http", AppsDomain: "oncarlos.test", }, cst) if err != nil { t.Fatal(err) } const addr = "paul@keymail.dev" if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil { t.Fatal(err) } if _, err := cst.CreateAppIn(console.OpsAccountID, "titogo", addr, time.Now()); err != nil { t.Fatal(err) } token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop") credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt}) if err := credFile.save(); err != nil { t.Fatal(err) } // A box shipped two chunks: routine output, then a wake failure — the // #131 case, as the shipping loop would have written it. now := time.Now().UTC() writeChunk := func(seq uint64, recs []applog.Record) { t.Helper() body, err := applog.EncodeChunk(recs) if err != nil { t.Fatal(err) } key, err := applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(recs[0].TS), seq, len(body)) if err != nil { t.Fatal(err) } if _, err := relStore.Put(context.Background(), key, body, ""); err != nil { t.Fatal(err) } } writeChunk(100, []applog.Record{ {TS: now.Add(-20 * time.Minute), Box: "box1", Host: "titogo.bab.oncarlos.test", Stream: applog.StreamApp, Msg: "listening on socket", Seq: 100, Fields: map[string]string{"request_id": "r1"}}, }) writeChunk(101, []applog.Record{ {TS: now.Add(-2 * time.Minute), Box: "box1", Host: "titogo.bab.oncarlos.test", Stream: applog.StreamPlatform, Level: applog.LevelError, Seq: 101, Msg: "wake failed: nothing is promoted for this instance's channel"}, }) // The plain read: both streams merged, oldest first, wake reason visible. buf := captureStdout(t) runLogs([]string{"-app", "titogo", "-account", "ops"}) out := buf.String() if !strings.Contains(out, "listening on socket") || !strings.Contains(out, "wake failed") { t.Fatalf("logs stdout missing the timeline:\n%s", out) } if strings.Index(out, "listening on socket") > strings.Index(out, "wake failed") { t.Fatalf("timeline out of order:\n%s", out) } if !strings.Contains(out, "[platform]") || !strings.Contains(out, "[app]") { t.Fatalf("stream tags missing:\n%s", out) } if !strings.Contains(out, "request_id=r1") { t.Fatalf("structured fields missing:\n%s", out) } // Filters reach the wire: -stream platform drops the app line. buf.Reset() runLogs([]string{"-app", "titogo", "-account", "ops", "-stream", "platform"}) if out := buf.String(); strings.Contains(out, "listening on socket") || !strings.Contains(out, "wake failed") { t.Fatalf("--stream platform: %q", out) } // -f follows: a line shipped between polls arrives; the poll cap is the // test's exit. logsMaxPolls = 2 t.Cleanup(func() { logsMaxPolls = 0 }) done := make(chan string, 1) go func() { fbuf := captureStdout(t) runLogs([]string{"-app", "titogo", "-account", "ops", "-f"}) done <- fbuf.String() }() time.Sleep(500 * time.Millisecond) // after the initial read, before poll 1 lands writeChunk(102, []applog.Record{ {TS: now.Add(-time.Second), Box: "box1", Host: "titogo.bab.oncarlos.test", Stream: applog.StreamApp, Msg: "fresh follow line", Seq: 102}, }) select { case out := <-done: if !strings.Contains(out, "fresh follow line") { t.Fatalf("follow missed the new line:\n%s", out) } case <-time.After(15 * time.Second): t.Fatal("follow did not finish its bounded polls") } // The whole flow ran through the console: nothing here may have touched // a local registry. checkNoRegistry() }