| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/base64" |
| 6 | "encoding/json" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "net/url" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "amadan.net/oficina/home/connect" |
| 15 | "amadan.net/rastrillo/rastrillo/assertion" |
| 16 | assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| 17 | "amadan.net/rastrillo/rastrillo/sessions" |
| 18 | ) |
| 19 | |
| 20 | const homeTestIssuer = "https://id.example.com" |
| 21 | |
| 22 | type homeFixture struct { |
| 23 | app *App |
| 24 | key *assertioncrypto.Keypair |
| 25 | jar map[string]*http.Cookie |
| 26 | } |
| 27 | |
| 28 | func newHomeFixture(t *testing.T) *homeFixture { |
| 29 | t.Helper() |
| 30 | return newHomeFixtureWith(t, "") |
| 31 | } |
| 32 | |
| 33 | // newHomeFixtureWith is newHomeFixture with the hub directory origin set |
| 34 | // (HOME_DIRECTORY_ORIGIN), which is what turns the app switcher on. |
| 35 | func newHomeFixtureWith(t *testing.T, directory string) *homeFixture { |
| 36 | t.Helper() |
| 37 | app, _ := appWithOrigin(t, testOrigin) |
| 38 | key, err := assertioncrypto.Generate() |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | keys, _ := json.Marshal(map[string]string{"key": base64.StdEncoding.EncodeToString(key.SignPub())}) |
| 43 | cfg := connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "sheets-preview", KeysBase64: base64.StdEncoding.EncodeToString(keys), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{81}, 32)), Directory: directory} |
| 44 | d, err := app.db.DB() |
| 45 | if err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | localAuth, err := NewAuth(d, AuthEnv{Origin: testOrigin, InstanceKey: "test-instance-key", Mailer: app.testMailer(t), Home: &cfg}, quietLogger(), app) |
| 49 | if err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | t.Cleanup(func() { CloseSigninMail(localAuth) }) |
| 53 | app.UseAuth(localAuth) |
| 54 | if err := app.ConfigureHome(&cfg); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | return &homeFixture{app: app, key: key, jar: map[string]*http.Cookie{}} |
| 58 | } |
| 59 | func (f *homeFixture) request(t *testing.T, method, path string, form url.Values, issuer bool) *httptest.ResponseRecorder { |
| 60 | t.Helper() |
| 61 | body := "" |
| 62 | if form != nil { |
| 63 | body = form.Encode() |
| 64 | } |
| 65 | r := httptest.NewRequest(method, testOrigin+path, strings.NewReader(body)) |
| 66 | if method == "POST" { |
| 67 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 68 | origin := testOrigin |
| 69 | if issuer { |
| 70 | origin = homeTestIssuer |
| 71 | } |
| 72 | r.Header.Set("Origin", origin) |
| 73 | } |
| 74 | if !issuer { |
| 75 | for _, cookie := range f.jar { |
| 76 | r.AddCookie(cookie) |
| 77 | } |
| 78 | } |
| 79 | w := httptest.NewRecorder() |
| 80 | f.app.Router().ServeHTTP(w, r) |
| 81 | for _, cookie := range w.Result().Cookies() { |
| 82 | if cookie.MaxAge < 0 { |
| 83 | delete(f.jar, cookie.Name) |
| 84 | } else { |
| 85 | f.jar[cookie.Name] = cookie |
| 86 | } |
| 87 | } |
| 88 | return w |
| 89 | } |
| 90 | func (f *homeFixture) local(t *testing.T, address string) Member { |
| 91 | t.Helper() |
| 92 | m, err := f.app.memberFor(address) |
| 93 | if err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | token, err := f.app.homeSessions.Mint(sessions.Session{Subject: address, Method: "magiclink"}) |
| 97 | if err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | f.jar[f.app.homeSessions.CookieName()] = &http.Cookie{Name: f.app.homeSessions.CookieName(), Value: token} |
| 101 | return m |
| 102 | } |
| 103 | func (f *homeFixture) exchange(t *testing.T, start *httptest.ResponseRecorder, subject string) *httptest.ResponseRecorder { |
| 104 | t.Helper() |
| 105 | if start.Code != 303 { |
| 106 | t.Fatal("start", start.Code, start.Body.String()) |
| 107 | } |
| 108 | target, err := url.Parse(start.Header().Get("Location")) |
| 109 | if err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | if target.Host != "id.example.com" || target.Query().Get("consumer") != "sheets-preview" { |
| 113 | t.Fatal("wrong Home destination", target.Host) |
| 114 | } |
| 115 | now := time.Now().Unix() |
| 116 | id, _ := assertion.RandomID() |
| 117 | token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: homeTestIssuer, Audience: testOrigin, Subject: subject, KeyID: "key", ID: id, Nonce: target.Query().Get("nonce"), IssuedAt: now, ExpiresAt: now + 120, AuthTime: now - 10}) |
| 118 | if err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | callback := f.request(t, "POST", "/auth/assertion", url.Values{"assertion": {token}}, true) |
| 122 | if callback.Code != 303 { |
| 123 | t.Fatal("callback", callback.Code, callback.Body.String()) |
| 124 | } |
| 125 | return f.request(t, "GET", callback.Header().Get("Location"), nil, false) |
| 126 | } |
| 127 | func (f *homeFixture) link(t *testing.T, subject string) { |
| 128 | t.Helper() |
| 129 | start := f.request(t, "POST", "/account/home", nil, false) |
| 130 | if start.Code != 303 { |
| 131 | t.Fatal("link", start.Code, start.Body.String()) |
| 132 | } |
| 133 | authStart := f.request(t, "GET", start.Header().Get("Location"), nil, false) |
| 134 | target, _ := url.Parse(authStart.Header().Get("Location")) |
| 135 | if target.Query().Get("max_age") != "300" { |
| 136 | t.Fatal("link missing fresh Home proof") |
| 137 | } |
| 138 | finish := f.exchange(t, authStart, subject) |
| 139 | if finish.Code != 303 || finish.Header().Get("Location") != "/account/home/confirm" { |
| 140 | t.Fatal("finish link", finish.Code, finish.Body.String()) |
| 141 | } |
| 142 | page := f.request(t, "GET", "/account/home/confirm", nil, false) |
| 143 | if page.Code != 200 || !strings.Contains(page.Body.String(), "Confirm account connection") { |
| 144 | t.Fatal("confirm page", page.Code, page.Body.String()) |
| 145 | } |
| 146 | confirmed := f.request(t, "POST", "/account/home/confirm", nil, false) |
| 147 | if confirmed.Code != 303 { |
| 148 | t.Fatal("confirm", confirmed.Code, confirmed.Body.String()) |
| 149 | } |
| 150 | } |
| 151 | func TestHomeConnectionPreservesSheetsAndCurrentAdmission(t *testing.T) { |
| 152 | f := newHomeFixture(t) |
| 153 | member := f.local(t, signedInAddress) |
| 154 | sheet, err := f.app.createSheet("Existing linked workbook", member) |
| 155 | if err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | f.link(t, "opaque-home-subject") |
| 159 | f.jar = map[string]*http.Cookie{} |
| 160 | signin := f.request(t, "GET", "/signin?return_to=%2Fsheets", nil, false) |
| 161 | start := f.request(t, "GET", signin.Header().Get("Location"), nil, false) |
| 162 | finish := f.exchange(t, start, "opaque-home-subject") |
| 163 | if finish.Code != 303 || finish.Header().Get("Location") != "/sheets" { |
| 164 | t.Fatal("ordinary sign-in", finish.Code, finish.Body.String()) |
| 165 | } |
| 166 | page := f.request(t, "GET", "/sheets", nil, false) |
| 167 | if page.Code != 200 || !strings.Contains(page.Body.String(), "Existing linked workbook") || strings.Contains(page.Body.String(), "opaque-home-subject") { |
| 168 | t.Fatal("existing sheet missing or subject exposed", page.Code) |
| 169 | } |
| 170 | var members int64 |
| 171 | if err := f.app.db.Model(&Member{}).Count(&members).Error; err != nil || members != 1 { |
| 172 | t.Fatal("Home created members", members, err) |
| 173 | } |
| 174 | r := httptest.NewRequest("GET", testOrigin+"/sheets", nil) |
| 175 | for _, c := range f.jar { |
| 176 | r.AddCookie(c) |
| 177 | } |
| 178 | actor, ok := f.app.actor(r) |
| 179 | if !ok || actor.ID != member.ID { |
| 180 | t.Fatal("owner changed") |
| 181 | } |
| 182 | if !f.app.mayWrite(sheet.ID, actor) { |
| 183 | t.Fatal("existing grant changed") |
| 184 | } |
| 185 | f.app.admit = func(string) bool { return false } |
| 186 | if err := f.app.db.Where("member_id = ?", member.ID).Delete(&Grant{}).Error; err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | denied := f.request(t, "GET", "/sheets", nil, false) |
| 190 | if denied.Code != 303 || strings.Contains(denied.Body.String(), "Existing linked workbook") { |
| 191 | t.Fatal("revoked member admitted", denied.Code) |
| 192 | } |
| 193 | if _, ok := f.jar[f.app.homeSessions.CookieName()]; ok { |
| 194 | t.Fatal("revoked session retained") |
| 195 | } |
| 196 | } |
| 197 | func TestHomeUnknownAndMalformedFlowsNeverCreateMembers(t *testing.T) { |
| 198 | f := newHomeFixture(t) |
| 199 | start := f.request(t, "GET", "/auth/start?return_to=%2Fsheets", nil, false) |
| 200 | finish := f.exchange(t, start, "unknown-subject") |
| 201 | if finish.Code != 403 || !strings.Contains(finish.Body.String(), "/signin/local") { |
| 202 | t.Fatal("unknown identity accepted", finish.Code) |
| 203 | } |
| 204 | var members int64 |
| 205 | f.app.db.Model(&Member{}).Count(&members) |
| 206 | if members != 0 { |
| 207 | t.Fatal("Home created local identity", members) |
| 208 | } |
| 209 | if _, ok := f.jar[f.app.homeSessions.CookieName()]; ok { |
| 210 | t.Fatal("unknown identity minted session") |
| 211 | } |
| 212 | req := httptest.NewRequest("POST", testOrigin+"/account/home", nil) |
| 213 | req.Header.Set("Origin", "https://evil.example") |
| 214 | rec := httptest.NewRecorder() |
| 215 | f.app.Router().ServeHTTP(rec, req) |
| 216 | if rec.Code != 403 { |
| 217 | t.Fatal("cross-origin link allowed", rec.Code) |
| 218 | } |
| 219 | } |
| 220 | |