| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/rand" |
| 7 | "crypto/sha256" |
| 8 | "encoding/base64" |
| 9 | "encoding/json" |
| 10 | "fmt" |
| 11 | "io" |
| 12 | "net/http" |
| 13 | "net/http/httptest" |
| 14 | "os" |
| 15 | "os/exec" |
| 16 | "path/filepath" |
| 17 | "strings" |
| 18 | "sync/atomic" |
| 19 | "testing" |
| 20 | "time" |
| 21 | ) |
| 22 | |
| 23 | // TestNativeReview uses an isolated migrated database, real browser sign-in and |
| 24 | // the shared native approval/exchange. It never contacts a production account. |
| 25 | func TestNativeReview(t *testing.T) { |
| 26 | binary, simulator := os.Getenv("NATIVE_REVIEW_BINARY"), os.Getenv("NATIVE_REVIEW_SIMULATOR") |
| 27 | if binary == "" && simulator == "" { |
| 28 | t.Skip("set an isolated native review binary or simulator") |
| 29 | } |
| 30 | output := os.Getenv("NATIVE_REVIEW_OUTPUT") |
| 31 | if output == "" { |
| 32 | t.Fatal("NATIVE_REVIEW_OUTPUT is required") |
| 33 | } |
| 34 | a, browser := signIn(t) |
| 35 | seed, cells := newSheet(t, a, browser, "Native review workbook") |
| 36 | for _, raw := range []string{`{"r":0,"c":0,"raw":"Item"}`, `{"r":0,"c":1,"raw":"Hours"}`, `{"r":1,"c":0,"raw":"Design"}`, `{"r":1,"c":1,"raw":"12"}`, `{"r":2,"c":0,"raw":"Engineering"}`, `{"r":2,"c":1,"raw":"24"}`, `{"r":3,"c":0,"raw":"Total"}`, `{"r":3,"c":1,"raw":"=SUM(B2:B3)"}`} { |
| 37 | if w := postCell(t, a, browser, cells, raw); w.Code != 200 { |
| 38 | t.Fatal("seed write refused", w.Code) |
| 39 | } |
| 40 | } |
| 41 | // Seed helpers use relative requests; the live fixture then enables the same |
| 42 | // canonical-origin protection used by the production app. |
| 43 | a.origin = testOrigin |
| 44 | if err := a.ConfigureNative(); err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | router := a.Router() |
| 48 | credential := reviewNativeCredential(t, router, browser) |
| 49 | var refusedSyntheticEdit atomic.Bool |
| 50 | var requestedExport atomic.Bool |
| 51 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 52 | // Test-only listener adapter. Credentials remain real; only the ephemeral |
| 53 | // loopback origin is mapped to the fixture's fixed canonical origin. |
| 54 | r.Header.Set("Origin", testOrigin) |
| 55 | r.Host = strings.TrimPrefix(testOrigin, "https://") |
| 56 | r.URL.Scheme = "https" |
| 57 | r.URL.Host = r.Host |
| 58 | if os.Getenv("NATIVE_REVIEW_UI_TESTS") != "" && r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/cells/batch") { |
| 59 | body, err := io.ReadAll(io.LimitReader(r.Body, (1<<20)+1)) |
| 60 | if err != nil { |
| 61 | http.Error(w, "Isolated fixture could not read edit.", 400) |
| 62 | return |
| 63 | } |
| 64 | r.Body = io.NopCloser(bytes.NewReader(body)) |
| 65 | if bytes.Contains(body, []byte(`"raw":"=6*7"`)) && refusedSyntheticEdit.CompareAndSwap(false, true) { |
| 66 | http.Error(w, "One isolated test edit was deliberately refused.", 503) |
| 67 | return |
| 68 | } |
| 69 | } |
| 70 | if r.Method == "GET" && strings.HasSuffix(r.URL.Path, "/export.xlsx") { |
| 71 | requestedExport.Store(true) |
| 72 | } |
| 73 | router.ServeHTTP(w, r) |
| 74 | })) |
| 75 | defer server.Close() |
| 76 | defer server.CloseClientConnections() |
| 77 | input, err := json.Marshal(map[string]any{"origin": server.URL, "credential": credential, "output": output}) |
| 78 | if err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | if simulator == "" { |
| 82 | cmd := exec.CommandContext(t.Context(), binary) |
| 83 | cmd.Stdin = bytes.NewReader(input) |
| 84 | if log, err := cmd.CombinedOutput(); err != nil { |
| 85 | t.Fatalf("native review failed: %v: %s", err, log) |
| 86 | } |
| 87 | return |
| 88 | } |
| 89 | bundle := "net.amadan.oficina.sheets.review" |
| 90 | run := func(args ...string) []byte { |
| 91 | t.Helper() |
| 92 | out, err := exec.CommandContext(t.Context(), "xcrun", append([]string{"simctl"}, args...)...).CombinedOutput() |
| 93 | if err != nil { |
| 94 | t.Fatalf("simulator fixture failed: %v: %s", err, out) |
| 95 | } |
| 96 | return out |
| 97 | } |
| 98 | container := strings.TrimSpace(string(run("get_app_container", simulator, bundle, "data"))) |
| 99 | path := filepath.Join(container, "Documents", "review.json") |
| 100 | if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | if err := os.WriteFile(path, input, 0600); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | defer func() { |
| 107 | _ = os.Remove(path) |
| 108 | // Xcode can migrate a data container while reinstalling the review app. |
| 109 | // Resolve again so cleanup follows the file to its current container. |
| 110 | current, err := exec.Command("xcrun", "simctl", "get_app_container", simulator, bundle, "data").Output() |
| 111 | if err != nil { |
| 112 | t.Errorf("resolve review credential cleanup: %v", err) |
| 113 | return |
| 114 | } |
| 115 | credentialPath := filepath.Join(strings.TrimSpace(string(current)), "Documents", "review.json") |
| 116 | if err := os.Remove(credentialPath); err != nil && !os.IsNotExist(err) { |
| 117 | t.Errorf("remove review credential: %v", err) |
| 118 | } |
| 119 | staged, _ := filepath.Glob(filepath.Join(strings.TrimSpace(string(current)), "tmp", "oficina-sheets-export-*")) |
| 120 | for _, file := range staged { |
| 121 | if err := os.RemoveAll(file); err != nil { |
| 122 | t.Errorf("remove isolated export artifact: %v", err) |
| 123 | } |
| 124 | } |
| 125 | }() |
| 126 | defer exec.Command("xcrun", "simctl", "terminate", simulator, bundle).Run() |
| 127 | if os.Getenv("NATIVE_REVIEW_UI_TESTS") != "" { |
| 128 | resultBundle := filepath.Join(t.TempDir(), "Review.xcresult") |
| 129 | commandContext, cancel := context.WithTimeout(t.Context(), 6*time.Minute) |
| 130 | defer cancel() |
| 131 | command := exec.CommandContext(commandContext, "xcodebuild", "-project", "native/ios/SheetsPhone.xcodeproj", "-scheme", "SheetsReviewPhone", "-destination", "platform=iOS Simulator,id="+simulator, "-parallel-testing-enabled", "NO", "-derivedDataPath", "native/build/review-ios", "CODE_SIGNING_ALLOWED=NO", "-resultBundlePath", resultBundle, "test-without-building") |
| 132 | command.Dir = "../.." |
| 133 | only := os.Getenv("NATIVE_REVIEW_UI_ONLY") |
| 134 | if only != "" { |
| 135 | command.Args = append(command.Args, "-only-testing:SheetsPhoneUITests/"+only) |
| 136 | } |
| 137 | logPath := filepath.Join(os.TempDir(), "sheets-native-ui-"+simulator+".log") |
| 138 | logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600) |
| 139 | if err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | defer logFile.Close() |
| 143 | command.Stdout, command.Stderr = logFile, logFile |
| 144 | command.WaitDelay = 5 * time.Second |
| 145 | if err := command.Run(); err != nil { |
| 146 | t.Fatal("Native UI tests failed; see", logPath, err) |
| 147 | } |
| 148 | if only == "" { |
| 149 | verifyReviewFormula(t, a, credential, seed.ID, true, true) |
| 150 | if !refusedSyntheticEdit.Load() || !requestedExport.Load() { |
| 151 | t.Fatal("Native retry and export controls did not reach their isolated server paths") |
| 152 | } |
| 153 | } else if strings.Contains(only, "CreateAndEdit") { |
| 154 | verifyReviewFormula(t, a, credential, seed.ID, true, false) |
| 155 | } else if strings.Contains(only, "FailedCellEdit") { |
| 156 | verifyReviewFormula(t, a, credential, seed.ID, false, true) |
| 157 | if !refusedSyntheticEdit.Load() { |
| 158 | t.Fatal("Retry UI did not exercise the isolated write refusal") |
| 159 | } |
| 160 | } else if strings.Contains(only, "Export") && !requestedExport.Load() { |
| 161 | t.Fatal("Native export control did not reach the isolated server") |
| 162 | } |
| 163 | current := strings.TrimSpace(string(run("get_app_container", simulator, bundle, "data"))) |
| 164 | staged, err := filepath.Glob(filepath.Join(current, "tmp", "oficina-sheets-export-*")) |
| 165 | if err != nil || len(staged) != 0 { |
| 166 | t.Fatal("Cancelled native export left an unsealed staged workbook") |
| 167 | } |
| 168 | attachments := filepath.Join(t.TempDir(), "attachments") |
| 169 | export := exec.CommandContext(t.Context(), "xcrun", "xcresulttool", "export", "attachments", "--path", resultBundle, "--output-path", attachments) |
| 170 | if log, err := export.CombinedOutput(); err != nil { |
| 171 | t.Fatalf("export native screenshots: %v: %s", err, log) |
| 172 | } |
| 173 | data, err := os.ReadFile(filepath.Join(attachments, "manifest.json")) |
| 174 | if err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | var manifest []struct { |
| 178 | Attachments []struct { |
| 179 | ExportedFileName string |
| 180 | SuggestedHumanReadableName string |
| 181 | } |
| 182 | } |
| 183 | if err := json.Unmarshal(data, &manifest); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | for _, test := range manifest { |
| 187 | for _, attachment := range test.Attachments { |
| 188 | if strings.Contains(attachment.SuggestedHumanReadableName, "Sheets native") { |
| 189 | image, err := os.ReadFile(filepath.Join(attachments, filepath.Base(attachment.ExportedFileName))) |
| 190 | if err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | if err := os.WriteFile(output, image, 0600); err != nil { |
| 194 | t.Fatal(err) |
| 195 | } |
| 196 | return |
| 197 | } |
| 198 | } |
| 199 | } |
| 200 | t.Fatal("native formula screenshot attachment missing") |
| 201 | } |
| 202 | run("launch", simulator, bundle) |
| 203 | time.Sleep(3 * time.Second) |
| 204 | run("io", simulator, "screenshot", output) |
| 205 | } |
| 206 | |
| 207 | func reviewNativeCredential(t *testing.T, handler http.Handler, browser []*http.Cookie) map[string]string { |
| 208 | t.Helper() |
| 209 | proof := make([]byte, 32) |
| 210 | if _, err := rand.Read(proof); err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | verifier := base64.RawURLEncoding.EncodeToString(proof) |
| 214 | hash := sha256.Sum256([]byte(verifier)) |
| 215 | call := func(path, body string, signed bool) *httptest.ResponseRecorder { |
| 216 | r := httptest.NewRequest("POST", testOrigin+path, strings.NewReader(body)) |
| 217 | r.Header.Set("Origin", testOrigin) |
| 218 | r.Header.Set("Content-Type", "application/json") |
| 219 | if signed { |
| 220 | for _, c := range browser { |
| 221 | r.AddCookie(c) |
| 222 | } |
| 223 | } |
| 224 | w := httptest.NewRecorder() |
| 225 | handler.ServeHTTP(w, r) |
| 226 | return w |
| 227 | } |
| 228 | w := call("/native/request", `{"challenge":"`+base64.RawURLEncoding.EncodeToString(hash[:])+`"}`, false) |
| 229 | var pending struct{ ID string } |
| 230 | if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &pending) != nil || pending.ID == "" { |
| 231 | t.Fatal("native begin failed", w.Code) |
| 232 | } |
| 233 | if w = call("/native/authorize?id="+pending.ID, "", true); w.Code != 200 { |
| 234 | t.Fatal("native approval failed", w.Code) |
| 235 | } |
| 236 | w = call("/native/exchange", `{"id":"`+pending.ID+`","verifier":"`+verifier+`"}`, false) |
| 237 | var session struct{ Name, Value string } |
| 238 | if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &session) != nil || session.Value == "" { |
| 239 | t.Fatal("native exchange failed", w.Code) |
| 240 | } |
| 241 | return map[string]string{"name": session.Name, "value": session.Value} |
| 242 | } |
| 243 | |
| 244 | // Verify the UI's result through a fresh authenticated read, so rendering a |
| 245 | // optimistic value without persistence cannot satisfy this fixture. |
| 246 | func verifyReviewFormula(t *testing.T, a *App, credential map[string]string, seedID uint64, requireMain, requireRetry bool) { |
| 247 | t.Helper() |
| 248 | get := func(path string) *httptest.ResponseRecorder { |
| 249 | request := nativeGet(path) |
| 250 | request.AddCookie(&http.Cookie{Name: credential["name"], Value: credential["value"]}) |
| 251 | return do(t, a, request) |
| 252 | } |
| 253 | var list struct{ Sheets []SheetSummary } |
| 254 | response := get("/sheets") |
| 255 | if response.Code != 200 || json.Unmarshal(response.Body.Bytes(), &list) != nil { |
| 256 | t.Fatal("native verification list failed") |
| 257 | } |
| 258 | verifiedMain, verifiedRetry := false, false |
| 259 | for _, sheet := range list.Sheets { |
| 260 | if sheet.ID == seedID { |
| 261 | continue |
| 262 | } |
| 263 | response = get(fmt.Sprintf("/api/sheets/%d/tabs/%d/cells?rows=2&cols=2", sheet.ID, sheet.TabID)) |
| 264 | if response.Code != 200 { |
| 265 | t.Fatal("native verification grid failed", response.Code) |
| 266 | } |
| 267 | grid := decodeGrid(t, response) |
| 268 | verified := 0 |
| 269 | for _, cell := range grid.Cells { |
| 270 | if cell.R == 0 && cell.C == 0 && cell.Raw == "=6*7" && cell.Display == "42" { |
| 271 | verifiedRetry = true |
| 272 | } |
| 273 | want := "42" |
| 274 | if cell.R == 0 && cell.C == 1 { |
| 275 | want = "42.00" |
| 276 | } |
| 277 | if cell.R < 2 && cell.C < 2 && cell.Raw == "=7*6" && cell.Display == want { |
| 278 | verified++ |
| 279 | } |
| 280 | } |
| 281 | if verified == 4 { |
| 282 | verifiedMain = true |
| 283 | } |
| 284 | } |
| 285 | if (!requireMain || verifiedMain) && (!requireRetry || verifiedRetry) { |
| 286 | return |
| 287 | } |
| 288 | t.Fatal("Native formula, fill, range paste, formatting and history restore did not persist") |
| 289 | } |
| 290 | |