| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strconv" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "lchat/internal/client" |
| 13 | "lchat/internal/e2ee" |
| 14 | ) |
| 15 | |
| 16 | // fakeHome serves a sealed vault blob the way home's GET /api/blob does — |
| 17 | // bearer-gated, opaque bytes. |
| 18 | func fakeHome(t *testing.T, token string, envelope []byte) *httptest.Server { |
| 19 | t.Helper() |
| 20 | mux := http.NewServeMux() |
| 21 | mux.HandleFunc("GET /api/blob", func(w http.ResponseWriter, r *http.Request) { |
| 22 | if r.Header.Get("Authorization") != "Bearer "+token { |
| 23 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 24 | return |
| 25 | } |
| 26 | w.Header().Set("Content-Type", "application/octet-stream") |
| 27 | w.Write(envelope) |
| 28 | }) |
| 29 | ts := httptest.NewServer(mux) |
| 30 | t.Cleanup(ts.Close) |
| 31 | return ts |
| 32 | } |
| 33 | |
| 34 | func sealVault(t *testing.T, v *client.Vault) (envelope []byte, dekB64 string) { |
| 35 | t.Helper() |
| 36 | dek, err := e2ee.GenerateKey() |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | envelope, err = client.SealHomeBlob(v, dek, map[string]string{"cred-1": "opaque"}) |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | return envelope, e2ee.B64uEncode(dek) |
| 45 | } |
| 46 | |
| 47 | // The silent re-sync path: a saved home session pulls the vault and fills the |
| 48 | // store — no ceremony, no browser. |
| 49 | func TestSigninResyncFillsStore(t *testing.T) { |
| 50 | vault := &client.Vault{V: 1, Servers: []client.VaultServer{ |
| 51 | {URL: "https://alpha.11ch.at", Name: "Alpha", Starred: true, |
| 52 | Credentials: &client.VaultCredentials{Token: "tokA", UserPub: "pubA", UserPriv: "privA", Name: "Paul"}}, |
| 53 | {URL: "https://gone.11ch.at", Name: "Old", Archived: true, |
| 54 | Credentials: &client.VaultCredentials{Token: "tokB", UserPub: "pubB", UserPriv: "privB"}}, |
| 55 | {URL: "https://bare.11ch.at", Name: "Bookmark"}, |
| 56 | }} |
| 57 | envelope, dekB64 := sealVault(t, vault) |
| 58 | home := fakeHome(t, "hometok", envelope) |
| 59 | |
| 60 | dir := t.TempDir() |
| 61 | st, err := openStore(dir) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | if err := st.saveHomeSession(&client.HomeSession{V: 1, Base: home.URL, Token: "hometok", CredID: "cred-1", DEK: dekB64}); err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | |
| 69 | runSignin(st, nil) // dies (exits) on failure; success writes spaces |
| 70 | |
| 71 | sessions, err := st.spaces() |
| 72 | if err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | if len(sessions) != 1 { |
| 76 | t.Fatalf("want 1 restorable space, got %d", len(sessions)) |
| 77 | } |
| 78 | s := sessions[0] |
| 79 | if s.Base != "https://alpha.11ch.at" || s.Token != "tokA" || s.Name != "Paul" || |
| 80 | s.SpaceName != "Alpha" || !s.Starred || !s.LabelPublished { |
| 81 | t.Fatalf("restored session: %+v", s) |
| 82 | } |
| 83 | if _, err := os.Stat(filepath.Join(dir, "spaces", "alpha.11ch.at.json")); err != nil { |
| 84 | t.Fatalf("session file: %v", err) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // The loopback listener: the browser's redirect lands the ?d= payload with the |
| 89 | // terminal, the success page renders, and duplicate hits don't wedge it. |
| 90 | func TestLoopbackCallback(t *testing.T) { |
| 91 | lb, err := startLoopback() |
| 92 | if err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | defer lb.close() |
| 96 | |
| 97 | base := "http://127.0.0.1:" + strconv.Itoa(lb.port) |
| 98 | if resp, err := http.Get(base + "/cb"); err != nil || resp.StatusCode != http.StatusBadRequest { |
| 99 | t.Fatalf("missing d: %v %v", err, resp.Status) |
| 100 | } |
| 101 | for i := 0; i < 2; i++ { // a reload of the success page must not block |
| 102 | resp, err := http.Get(base + "/cb?d=sealed-code") |
| 103 | if err != nil || resp.StatusCode != http.StatusOK { |
| 104 | t.Fatalf("callback hit %d: %v", i, err) |
| 105 | } |
| 106 | resp.Body.Close() |
| 107 | } |
| 108 | select { |
| 109 | case d := <-lb.got: |
| 110 | if d != "sealed-code" { |
| 111 | t.Fatalf("got %q", d) |
| 112 | } |
| 113 | case <-time.After(2 * time.Second): |
| 114 | t.Fatal("payload never delivered") |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // Legacy migration: a single-space ~/.eleven/session.json from the pre-signin |
| 119 | // CLI moves into spaces/ on first store open. |
| 120 | func TestLegacySessionMigration(t *testing.T) { |
| 121 | dir := t.TempDir() |
| 122 | legacy := &client.Session{Base: "https://old.11ch.at", Token: "tok", Name: "Paul", UserPub: "p", UserPriv: "k"} |
| 123 | if err := legacy.Save(filepath.Join(dir, "session.json")); err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | st, err := openStore(dir) |
| 127 | if err != nil { |
| 128 | t.Fatal(err) |
| 129 | } |
| 130 | sessions, err := st.spaces() |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | if len(sessions) != 1 || sessions[0].Base != "https://old.11ch.at" { |
| 135 | t.Fatalf("migrated: %+v", sessions) |
| 136 | } |
| 137 | if _, err := os.Stat(filepath.Join(dir, "session.json")); !os.IsNotExist(err) { |
| 138 | t.Fatal("legacy file still present") |
| 139 | } |
| 140 | } |
| 141 | |