| 1 | package docs_test |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "crypto/sha256" |
| 6 | "encoding/base64" |
| 7 | "encoding/json" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "net/url" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/carlosframework/docs/internal/docs" |
| 15 | ) |
| 16 | |
| 17 | func TestNativeProofAndSessionRevocation(t *testing.T) { |
| 18 | app := members(t, "alice@example.com") |
| 19 | browser := signInCookies(t, app, "alice@example.com") |
| 20 | verifier := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{42}, 32)) |
| 21 | hash := sha256.Sum256([]byte(verifier)) |
| 22 | begin := nativeRequest(app, nil, "POST", "/native/request", `{"challenge":"`+base64.RawURLEncoding.EncodeToString(hash[:])+`"}`) |
| 23 | var pending struct{ ID string } |
| 24 | if begin.Code != 200 || json.Unmarshal(begin.Body.Bytes(), &pending) != nil || pending.ID == "" { |
| 25 | t.Fatalf("native begin: %d", begin.Code) |
| 26 | } |
| 27 | exchange := `{"id":"` + pending.ID + `","verifier":"` + verifier + `"}` |
| 28 | if got := nativeRequest(app, nil, "POST", "/native/exchange", exchange); got.Code != 202 || strings.Contains(got.Body.String(), `"value"`) { |
| 29 | t.Fatalf("pending leaks credential: %d", got.Code) |
| 30 | } |
| 31 | wrong := nativeRequest(app, nil, "POST", "/native/exchange", `{"id":"`+pending.ID+`","verifier":"wrong"}`) |
| 32 | if wrong.Code == 200 { |
| 33 | t.Fatal("wrong proof accepted") |
| 34 | } |
| 35 | if got := nativeRequest(app, browser, "POST", "/native/authorize?id="+pending.ID, ""); got.Code != 200 { |
| 36 | t.Fatalf("approve: %d", got.Code) |
| 37 | } |
| 38 | done := nativeRequest(app, nil, "POST", "/native/exchange", exchange) |
| 39 | var credential struct{ Name, Value string } |
| 40 | if done.Code != 200 || json.Unmarshal(done.Body.Bytes(), &credential) != nil || credential.Value == "" { |
| 41 | t.Fatalf("exchange: %d", done.Code) |
| 42 | } |
| 43 | native := []*http.Cookie{{Name: credential.Name, Value: credential.Value}} |
| 44 | if got := nativeRequest(app, native, "GET", "/", ""); got.Code != 200 { |
| 45 | t.Fatalf("native session rejected: %d", got.Code) |
| 46 | } |
| 47 | nativeRequest(app, native, "POST", "/signout", "") |
| 48 | if got := nativeRequest(app, native, "GET", "/", ""); got.Code != 303 { |
| 49 | t.Fatalf("revoked native session accepted: %d", got.Code) |
| 50 | } |
| 51 | if got := nativeRequest(app, browser, "GET", "/", ""); got.Code != 200 { |
| 52 | t.Fatalf("native signout incorrectly revoked browser: %d", got.Code) |
| 53 | } |
| 54 | request := httptest.NewRequest("POST", "/native/request", strings.NewReader(`{"challenge":"`+base64.RawURLEncoding.EncodeToString(hash[:])+`"}`)) |
| 55 | request.Header.Set("Origin", "https://foreign.invalid") |
| 56 | request.Header.Set("Sec-Fetch-Site", "cross-site") |
| 57 | recorder := httptest.NewRecorder() |
| 58 | app.Handler().ServeHTTP(recorder, request) |
| 59 | if recorder.Code != 403 { |
| 60 | t.Fatalf("cross-origin native begin: %d", recorder.Code) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | func nativeRequest(app *docs.App, cookies []*http.Cookie, method, path, form string) *httptest.ResponseRecorder { |
| 65 | r := httptest.NewRequest(method, path, strings.NewReader(form)) |
| 66 | r.Header.Set("Accept", "application/json") |
| 67 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 68 | if strings.HasPrefix(form, "{") { |
| 69 | r.Header.Set("Content-Type", "application/json") |
| 70 | } |
| 71 | r.Header.Set("Sec-Fetch-Site", "same-origin") |
| 72 | for _, cookie := range cookies { |
| 73 | r.AddCookie(cookie) |
| 74 | } |
| 75 | w := httptest.NewRecorder() |
| 76 | app.Handler().ServeHTTP(w, r) |
| 77 | return w |
| 78 | } |
| 79 | |
| 80 | func TestNativeMetadataPreservesPermissions(t *testing.T) { |
| 81 | app := members(t, "alice@example.com,bob@example.com") |
| 82 | alice := signInCookies(t, app, "alice@example.com") |
| 83 | bob := signInCookies(t, app, "bob@example.com") |
| 84 | created := nativeRequest(app, alice, "POST", "/documents", "title=Native+minutes") |
| 85 | if created.Code != 201 { |
| 86 | t.Fatalf("create: %d %s", created.Code, created.Body.String()) |
| 87 | } |
| 88 | var document struct{ Sqid, Title, GUID string } |
| 89 | if err := json.Unmarshal(created.Body.Bytes(), &document); err != nil || document.Sqid == "" || document.GUID == "" { |
| 90 | t.Fatalf("missing native identity: %s", created.Body.String()) |
| 91 | } |
| 92 | path := "/d/" + document.Sqid |
| 93 | if got := nativeRequest(app, bob, "GET", path, ""); got.Code != 404 { |
| 94 | t.Fatalf("foreign document: %d", got.Code) |
| 95 | } |
| 96 | grant := nativeRequest(app, alice, "POST", path+"/share", "address=bob%40example.com&role=reader") |
| 97 | if grant.Code != 200 { |
| 98 | t.Fatalf("grant: %d", grant.Code) |
| 99 | } |
| 100 | got := nativeRequest(app, bob, "GET", path, "") |
| 101 | var detail struct { |
| 102 | CanWrite bool `json:"can_write"` |
| 103 | CanShare bool `json:"can_share"` |
| 104 | GUID string `json:"guid"` |
| 105 | } |
| 106 | if got.Code != 200 || json.Unmarshal(got.Body.Bytes(), &detail) != nil || detail.GUID == "" || detail.CanWrite || detail.CanShare { |
| 107 | t.Fatalf("reader metadata: %d %s", got.Code, got.Body.String()) |
| 108 | } |
| 109 | if got.Header().Get("Cache-Control") != "no-store" { |
| 110 | t.Fatal("native content is cacheable") |
| 111 | } |
| 112 | if got := nativeRequest(app, bob, "GET", path+"/share", ""); got.Code != 404 { |
| 113 | t.Fatalf("reader sees sharing: %d", got.Code) |
| 114 | } |
| 115 | listing := nativeRequest(app, bob, "GET", "/?q=Native", "") |
| 116 | if listing.Code != 200 || !strings.Contains(listing.Body.String(), document.Sqid) { |
| 117 | t.Fatalf("shared search missing: %s", listing.Body.String()) |
| 118 | } |
| 119 | revoked := nativeRequest(app, alice, "POST", path+"/share/revoke", "address=bob%40example.com") |
| 120 | if revoked.Code != 200 { |
| 121 | t.Fatalf("revoke: %d", revoked.Code) |
| 122 | } |
| 123 | if got := nativeRequest(app, bob, "GET", path, ""); got.Code != 404 { |
| 124 | t.Fatalf("revoked reader: %d", got.Code) |
| 125 | } |
| 126 | listing = nativeRequest(app, bob, "GET", "/", "") |
| 127 | if strings.Contains(listing.Body.String(), document.Sqid) { |
| 128 | t.Fatal("revoked document remains in native list") |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | func TestNativePublicLinkShownOnceAndAnonymousRefused(t *testing.T) { |
| 133 | app := members(t, "alice@example.com") |
| 134 | alice := signInCookies(t, app, "alice@example.com") |
| 135 | sqid := createDoc(t, app, alice, "Published native") |
| 136 | path := "/d/" + sqid + "/share" |
| 137 | pub := nativeRequest(app, alice, "POST", path+"/link", "on=1") |
| 138 | var result struct { |
| 139 | Published bool `json:"published"` |
| 140 | URL string `json:"public_url"` |
| 141 | } |
| 142 | if pub.Code != 200 || json.Unmarshal(pub.Body.Bytes(), &result) != nil || !result.Published || result.URL == "" { |
| 143 | t.Fatalf("publish: %d %s", pub.Code, pub.Body.String()) |
| 144 | } |
| 145 | if pub.Header().Get("Cache-Control") != "no-store" { |
| 146 | t.Fatal("public token response cacheable") |
| 147 | } |
| 148 | url, err := url.Parse(result.URL) |
| 149 | if err != nil || !strings.HasPrefix(url.Path, "/p/") { |
| 150 | t.Fatal("invalid public link") |
| 151 | } |
| 152 | next := nativeRequest(app, alice, "GET", path, "") |
| 153 | if next.Code != 200 || strings.Contains(next.Body.String(), result.URL) { |
| 154 | t.Fatal("public token returned twice") |
| 155 | } |
| 156 | if got := nativeRequest(app, nil, "GET", "/", ""); got.Code != 303 { |
| 157 | t.Fatalf("anonymous native listing: %d", got.Code) |
| 158 | } |
| 159 | if got := nativeRequest(app, nil, "POST", "/documents", "title=No"); got.Code != 403 { |
| 160 | t.Fatalf("anonymous native creation: %d", got.Code) |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | func TestNativeDraftCreationRetryAndRename(t *testing.T) { |
| 165 | app := members(t, "alice@example.com,bob@example.com") |
| 166 | alice := signInCookies(t, app, "alice@example.com") |
| 167 | bob := signInCookies(t, app, "bob@example.com") |
| 168 | const form = "creation_token=dd1f5245-2d96-4f11-af6b-350de8c32896" |
| 169 | // Concurrent/lost-response retries must all resolve the same owner-scoped token. |
| 170 | results := make(chan *httptest.ResponseRecorder, 8) |
| 171 | for i := 0; i < 8; i++ { |
| 172 | go func() { results <- nativeRequest(app, alice, "POST", "/documents", form) }() |
| 173 | } |
| 174 | var sqid string |
| 175 | for i := 0; i < 8; i++ { |
| 176 | got := <-results |
| 177 | var doc struct{ Sqid string } |
| 178 | if got.Code != 201 || json.Unmarshal(got.Body.Bytes(), &doc) != nil { |
| 179 | t.Fatalf("create: %d %s", got.Code, got.Body.String()) |
| 180 | } |
| 181 | if sqid != "" && sqid != doc.Sqid { |
| 182 | t.Fatal("retry created another document") |
| 183 | } |
| 184 | sqid = doc.Sqid |
| 185 | } |
| 186 | path := "/d/" + sqid |
| 187 | if got := nativeRequest(app, alice, "POST", path+"/title", "title=Renamed"); got.Code != 204 { |
| 188 | t.Fatalf("rename: %d %s", got.Code, got.Body.String()) |
| 189 | } |
| 190 | var replay struct{ Sqid, Title string } |
| 191 | got := nativeRequest(app, alice, "POST", "/documents", form+"&title=Old") |
| 192 | if json.Unmarshal(got.Body.Bytes(), &replay) != nil || replay.Sqid != sqid || replay.Title != "Renamed" { |
| 193 | t.Fatalf("replay overwrote title: %s", got.Body.String()) |
| 194 | } |
| 195 | if got := nativeRequest(app, bob, "POST", path+"/title", "title=Stolen"); got.Code != 404 { |
| 196 | t.Fatalf("stranger rename: %d", got.Code) |
| 197 | } |
| 198 | nativeRequest(app, alice, "POST", path+"/share", "address=bob%40example.com&role=reader") |
| 199 | if got := nativeRequest(app, bob, "POST", path+"/title", "title=Reader"); got.Code != 404 { |
| 200 | t.Fatalf("reader rename: %d", got.Code) |
| 201 | } |
| 202 | nativeRequest(app, alice, "POST", path+"/share", "address=bob%40example.com&role=writer") |
| 203 | if got := nativeRequest(app, bob, "POST", path+"/title", "title=Writer"); got.Code != 204 { |
| 204 | t.Fatalf("writer rename: %d", got.Code) |
| 205 | } |
| 206 | if got := nativeRequest(app, alice, "POST", path+"/title", "title="+strings.Repeat("a", 1025)); got.Code != 400 { |
| 207 | t.Fatalf("oversized title: %d", got.Code) |
| 208 | } |
| 209 | other := nativeRequest(app, bob, "POST", "/documents", form) |
| 210 | if json.Unmarshal(other.Body.Bytes(), &replay) != nil || replay.Sqid == sqid { |
| 211 | t.Fatal("creation token escaped owner scope") |
| 212 | } |
| 213 | } |
| 214 | |