Artifact 921c318bd56b1e6decf207d8545b75d48bfb9cb4c01e0b5073df5e2db2d0a6ce:
- File
conformance/sync_server_test.go
— part of check-in
[80c72b27c7]
at
2026-10-11 13:57:34
on branch main
— test(conformance): sync-protocol conformance suite (#262) (#311)
* test(conformance): sync-protocol conformance suite (#262)
Checks go-libfossil against draft-fossil-sync-protocol-00, requirement by requirement: 149 requirements, 199 cases. Server cases send raw requests to Repo.XferHandler and, through `fossil http --in`, to fossil(1) on a copy of the same repository; client cases drive the public Sync and Clone against a scripted peer, with fossil(1)'s client run against the same script for most of them; HTTP cases cover redirects, status codes and media types; interop cases run whole exchanges in both directions.
Known failures are marked with the placeholder #0 until their issues are filed, so TestCoverage fails until then.
* test(conformance): sync suite review fixes (#262)
Cases for the requirements that had none where fossil(1) can show the behavior: server-version, the SHA3 gate, shun admission, cookie and cleartext logins, pushed configuration, ci-unlock, cluster brackets, the clone's unversioned catalog and the pull-only message. Two rows added: link ARG handling and the short uvigot.
A fossil(1) disagreement, a harness failure or a deviation other than the one named now fails a case whatever its mark. The server's log output is discarded in the tests.
* test(conformance): mark sync gaps with their issues; drop gaps v0.11.1 closed (#262)
v0.11.1's read gate and redirect policy make seven sync cases conform, so their gap marks are removed. The rest are marked with the issues that track each fix: #296 and #299-#310.
* test(conformance): accept either fossil refusal of a negative clone SEQNO
fossil 2.28's macOS build answers clone 3 -1 with clone_seqno 0 and its Linux build, which CI pins, with the invalid-sequence error alone. The draft leaves the reply open; the case now holds go-libfossil only to sending no content. (user: [email protected], size: 5920) [annotate] [blame] [check-ins using]
package conformance import ( "bufio" "bytes" "compress/zlib" "context" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" libfossil "github.com/danmestas/go-libfossil" "github.com/danmestas/go-libfossil/db" ) // A server case is a raw request, sent to go-libfossil's HTTP handler and, // through `fossil http --in`, to fossil(1) on a copy of the same // repository. fossil test-xfer cannot be the reference: in 2.28 it renders // the robot-defense page and crashes. // syncRequest is the HTTP request a server case sends. type syncRequest struct { method string // POST unless set path string // "/" unless set ctype string // typeCompressed unless set cookie string // the Cookie header, if any text []byte // card text, compressed on the wire when ctype says so raw []byte // the entity as sent, when set; text is then unused } // entity is the request body as sent. func (r syncRequest) entity() []byte { if r.raw != nil { return r.raw } if r.ctype == typeCompressed { return compress(r.text, zlib.DefaultCompression) } return r.text } // serverReply is what one implementation answered. type serverReply struct { fx *syncFixture who string // "go-libfossil" or "fossil(1)" status int ctype string raw []byte // the reply entity as received text []byte // the card text: raw, decompressed when compressed cards []wireCard repo string // the repository after the request } // replyOf decodes a reply entity into cards. A reply that is not a sync // reply (a status other than 200, or an HTML page) keeps its text and has // no cards. func replyOf(fx *syncFixture, who string, status int, ctype string, raw []byte) (*serverReply, error) { r := &serverReply{fx: fx, who: who, status: status, ctype: ctype, raw: raw, text: raw} if status != http.StatusOK || !strings.HasPrefix(ctype, "application/x-fossil") { return r, nil } text, err := bodyText(ctype, raw) if err != nil { return nil, fmt.Errorf("%s reply: %v", who, err) } r.text = text if r.cards, err = parseCards(text); err != nil { return nil, fmt.Errorf("%s reply: %v", who, err) } return r, nil } // serveGo sends req to go-libfossil's XferHandler on the repository at path. // A panic in the handler is reported as the failure it is. func serveGo(fx *syncFixture, path string, req syncRequest) (reply *serverReply, err error) { r, err := libfossil.Open(path) if err != nil { return nil, err } rec := httptest.NewRecorder() func() { defer func() { if p := recover(); p != nil { err = expectf("go-libfossil's handler panicked: %v", p) } }() httpReq := httptest.NewRequest(req.method, req.path, bytes.NewReader(req.entity())) httpReq.Header.Set("Content-Type", req.ctype) if req.cookie != "" { httpReq.Header.Set("Cookie", req.cookie) } r.XferHandler().ServeHTTP(rec, httpReq) }() if closeErr := r.Close(); err == nil { err = closeErr } if err != nil { return nil, err } reply, err = replyOf(fx, "go-libfossil", rec.Code, rec.Header().Get("Content-Type"), rec.Body.Bytes()) if reply != nil { reply.repo = path } return reply, err } // serve sends req to fossil(1) on the repository at path, through // `fossil http`, which reads one raw HTTP request and writes the reply. func (f *fossilReference) serve(fx *syncFixture, path string, req syncRequest) (*serverReply, error) { status, ctype, raw, err := f.serveEntity(path, req) if err != nil { return nil, err } reply, err := replyOf(fx, "fossil(1)", status, ctype, raw) if reply != nil { reply.repo = path } return reply, err } // serveEntity sends one HTTP request to fossil(1) on the repository at // path, and returns the reply's status, Content-Type and entity. func (f *fossilReference) serveEntity(path string, req syncRequest) (int, string, []byte, error) { f.count++ reqFile := filepath.Join(f.dir, fmt.Sprintf("request-%d", f.count)) entity := req.entity() head := fmt.Sprintf("%s %s HTTP/1.0\r\nHost: 127.0.0.1\r\nContent-Type: %s\r\n"+ "Content-Length: %d\r\n", req.method, req.path, req.ctype, len(entity)) if req.cookie != "" { head += "Cookie: " + req.cookie + "\r\n" } head += "\r\n" if err := os.WriteFile(reqFile, append([]byte(head), entity...), 0o644); err != nil { return 0, "", nil, err } defer os.Remove(reqFile) ctx, cancel := context.WithTimeout(context.Background(), fossilTimeout) defer cancel() cmd := f.command(ctx, "http", path, "--in", reqFile, "--ipaddr", "127.0.0.1") cmd.Dir = f.dir out, err := cmd.Output() if err != nil { return 0, "", nil, fmt.Errorf("fossil http: %v: %.200q", err, out) } resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(out)), nil) if err != nil { return 0, "", nil, fmt.Errorf("fossil http reply: %v: %.200q", err, out) } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) return resp.StatusCode, resp.Header.Get("Content-Type"), raw, err } // prepare copies F into dir and applies a case's SQL to the copy. func prepare(fx *syncFixture, dir, name string, setup []string) (string, error) { path, err := fx.copyTo(dir, name) if err != nil || len(setup) == 0 { return path, err } conn, err := db.OpenSQL(path, db.OpenConfig{}, nil) if err != nil { return "", err } defer conn.Close() for _, stmt := range setup { if _, err := conn.Exec(fx.expand(stmt)); err != nil { return "", fmt.Errorf("setup %q: %v", stmt, err) } } return path, nil } // queryInt runs a query that returns one integer against the repository at // path, after its placeholders are expanded. func queryInt(fx *syncFixture, path, query string) (int, error) { conn, err := db.OpenSQL(path, db.OpenConfig{}, nil) if err != nil { return 0, err } defer conn.Close() var text string if err := conn.QueryRow(fx.expand(query)).Scan(&text); err != nil { return 0, fmt.Errorf("%s: %v", query, err) } return strconv.Atoi(text) }