oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
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.
25func 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
207func 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.
246func 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