| // Copyright 2026 The Go Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| package main | |
| import ( | |
| "bytes" | |
| "context" | |
| "encoding/json" | |
| "io" | |
| "net" | |
| "net/http" | |
| "net/http/httptest" | |
| "os" | |
| "path/filepath" | |
| "slices" | |
| "strings" | |
| "testing" | |
| "time" | |
| ) | |
| // TestBridge exercises one full session: a client connects and sends input, | |
| // a simulated page picks up the session, reads standard input, posts output | |
| // and an exit code, and the client observes them. | |
| func TestBridge(t *testing.T) { | |
| srv := &server{ | |
| token: "tok", | |
| queue: make(chan *session, 64), | |
| sessions: make(map[int]*session), | |
| agents: make(map[string]bool), | |
| } | |
| ts := httptest.NewServer(srv.mux()) | |
| defer ts.Close() | |
| clientConn, serverConn := net.Pipe() | |
| defer clientConn.Close() | |
| go srv.handleClient(serverConn) | |
| h, err := json.Marshal(hello{ | |
| Argv: []string{"fips140test.test"}, | |
| Env: map[string]string{"ACVP_WRAPPER": "1"}, | |
| }) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if err := writeFrame(clientConn, 'H', h); err != nil { | |
| t.Fatal(err) | |
| } | |
| go func() { | |
| writeFrame(clientConn, 'I', []byte("ping")) | |
| writeFrame(clientConn, 'E', nil) | |
| }() | |
| // Like a real client, always keep reading server frames. | |
| type frame struct { | |
| typ byte | |
| payload []byte | |
| } | |
| frames := make(chan frame, 16) | |
| go func() { | |
| defer close(frames) | |
| for { | |
| typ, payload, err := readFrame(clientConn) | |
| if err != nil { | |
| return | |
| } | |
| frames <- frame{typ, payload} | |
| } | |
| }() | |
| // The page picks up the session. | |
| resp, err := http.Get(ts.URL + "/tok/session") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| var meta struct { | |
| ID int `json:"id"` | |
| Argv []string `json:"argv"` | |
| Env map[string]string `json:"env"` | |
| } | |
| if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { | |
| t.Fatal(err) | |
| } | |
| resp.Body.Close() | |
| if got := meta.Argv[0]; got != "fips140test.test" { | |
| t.Errorf("argv[0] = %q", got) | |
| } | |
| if got := meta.Env["ACVP_WRAPPER"]; got != "1" { | |
| t.Errorf(`env["ACVP_WRAPPER"] = %q`, got) | |
| } | |
| // The page polls standard input a chunk at a time. The first poll returns | |
| // the client's input; the next, after EOF, sets the Stdin-Eof header and | |
| // returns an empty body. | |
| resp, err = http.Get(ts.URL + "/tok/stdin?s=1") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| stdin, err := io.ReadAll(resp.Body) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| resp.Body.Close() | |
| if !bytes.Equal(stdin, []byte("ping")) { | |
| t.Errorf("stdin = %q, want %q", stdin, "ping") | |
| } | |
| resp, err = http.Get(ts.URL + "/tok/stdin?s=1") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| eof, err := io.ReadAll(resp.Body) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| resp.Body.Close() | |
| if resp.Header.Get("Stdin-Eof") != "1" || len(eof) != 0 { | |
| t.Errorf("second stdin poll: Stdin-Eof=%q body=%q, want \"1\" and empty", | |
| resp.Header.Get("Stdin-Eof"), eof) | |
| } | |
| // The page posts output and the exit code... | |
| resp, err = http.Post(ts.URL+"/tok/io?s=1&fd=1", "application/octet-stream", | |
| strings.NewReader("pong")) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if resp.StatusCode != http.StatusOK { | |
| t.Fatalf("io: HTTP %d", resp.StatusCode) | |
| } | |
| resp, err = http.Post(ts.URL+"/tok/exit?s=1", "application/json", | |
| strings.NewReader(`{"code":3}`)) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if resp.StatusCode != http.StatusOK { | |
| t.Errorf("exit: HTTP %d", resp.StatusCode) | |
| } | |
| // ...and the client sees them. | |
| clientConn.SetReadDeadline(time.Now().Add(5 * time.Second)) | |
| f, ok := <-frames | |
| if !ok || f.typ != 'O' || !bytes.Equal(f.payload, []byte("pong")) { | |
| t.Errorf("frame = %c %q, want O pong", f.typ, f.payload) | |
| } | |
| f, ok = <-frames | |
| if !ok || f.typ != 'X' || len(f.payload) != 1 || f.payload[0] != 3 { | |
| t.Errorf("frame = %c %v, want X [3]", f.typ, f.payload) | |
| } | |
| if f, ok := <-frames; ok { | |
| t.Errorf("unexpected frame after exit: %c %q", f.typ, f.payload) | |
| } | |
| // The session is gone. | |
| resp, err = http.Get(ts.URL + "/tok/stdin?s=1") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| resp.Body.Close() | |
| if resp.StatusCode != http.StatusNotFound { | |
| t.Errorf("after exit: HTTP %d, want 404", resp.StatusCode) | |
| } | |
| } | |
| // TestStaleSessionSkipped checks that a session whose client disconnected | |
| // while queued is not handed to the page. | |
| func TestStaleSessionSkipped(t *testing.T) { | |
| srv := &server{ | |
| token: "tok", | |
| queue: make(chan *session, 64), | |
| sessions: make(map[int]*session), | |
| agents: make(map[string]bool), | |
| } | |
| // Session 1 was removed (client gone) but is still in the queue. | |
| srv.queue <- &session{id: 1, argv: []string{"one"}} | |
| live := &session{id: 2, argv: []string{"two"}, env: map[string]string{}} | |
| srv.sessions[2] = live | |
| srv.queue <- live | |
| ts := httptest.NewServer(srv.mux()) | |
| defer ts.Close() | |
| resp, err := http.Get(ts.URL + "/tok/session") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| defer resp.Body.Close() | |
| var meta struct { | |
| ID int `json:"id"` | |
| } | |
| if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { | |
| t.Fatal(err) | |
| } | |
| if meta.ID != 2 { | |
| t.Errorf("delivered session %d, want 2", meta.ID) | |
| } | |
| } | |
| // TestSessionRequeue checks that a session dequeued by a poll whose page | |
| // already went away is requeued rather than dropped. | |
| func TestSessionRequeue(t *testing.T) { | |
| srv := &server{ | |
| token: "tok", | |
| queue: make(chan *session, 64), | |
| sessions: make(map[int]*session), | |
| agents: make(map[string]bool), | |
| } | |
| s := &session{id: 1, argv: []string{"one"}, env: map[string]string{}} | |
| srv.sessions[1] = s | |
| ctx, cancel := context.WithCancel(t.Context()) | |
| cancel() | |
| for range 20 { | |
| srv.queue <- s | |
| r := httptest.NewRequest("GET", "/tok/session", nil).WithContext(ctx) | |
| srv.handleSession(httptest.NewRecorder(), r) | |
| select { | |
| case got := <-srv.queue: | |
| if got != s { | |
| t.Fatalf("queued session = %+v, want session 1", got) | |
| } | |
| default: | |
| t.Fatal("session was delivered to a dead page and dropped") | |
| } | |
| } | |
| } | |
| func TestCorruptModule(t *testing.T) { | |
| magic := append([]byte{0xff}, " Go fipsinfo \xff\x00"...) | |
| prefix := []byte("some wasm bytes ") | |
| sum := bytes.Repeat([]byte{0xab}, 32) | |
| suffix := []byte(" more wasm bytes") | |
| bin := slices.Concat(prefix, magic, sum, suffix) | |
| got, err := corruptModule(bin) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if !bytes.Equal(got, sum) { | |
| t.Errorf("returned sum = %x, want %x", got, sum) | |
| } | |
| want := slices.Concat(prefix, magic, bytes.Repeat([]byte("X"), 32), suffix) | |
| if !bytes.Equal(bin, want) { | |
| t.Errorf("corrupted module = %q, want %q", bin, want) | |
| } | |
| if _, err := corruptModule([]byte("no magic here")); err == nil { | |
| t.Error("expected error for missing magic") | |
| } | |
| if _, err := corruptModule(slices.Concat(magic, sum, magic, sum)); err == nil { | |
| t.Error("expected error for duplicate magic") | |
| } | |
| if _, err := corruptModule(slices.Concat(magic, []byte("short"))); err == nil { | |
| t.Error("expected error for truncated module") | |
| } | |
| } | |
| // TestCorruptSession checks that a corrupt session is advertised as such and | |
| // served a module with its checksum overwritten, while a normal session gets | |
| // the unmodified module. | |
| func TestCorruptSession(t *testing.T) { | |
| magic := append([]byte{0xff}, " Go fipsinfo \xff\x00"...) | |
| sum := bytes.Repeat([]byte{0xab}, 32) | |
| module := slices.Concat([]byte("\x00asm"), magic, sum, []byte("tail")) | |
| path := filepath.Join(t.TempDir(), "module.wasm") | |
| if err := os.WriteFile(path, module, 0o644); err != nil { | |
| t.Fatal(err) | |
| } | |
| srv := &server{ | |
| token: "tok", | |
| wasm: path, | |
| queue: make(chan *session, 64), | |
| sessions: make(map[int]*session), | |
| agents: make(map[string]bool), | |
| } | |
| srv.sessions[1] = &session{id: 1, env: map[string]string{}} | |
| srv.sessions[2] = &session{id: 2, env: map[string]string{}, corrupt: true} | |
| ts := httptest.NewServer(srv.mux()) | |
| defer ts.Close() | |
| get := func(path string) []byte { | |
| t.Helper() | |
| resp, err := http.Get(ts.URL + path) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| defer resp.Body.Close() | |
| body, err := io.ReadAll(resp.Body) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| return body | |
| } | |
| // The page's initial load (no session) gets the real module. | |
| if got := get("/tok/module.wasm"); !bytes.Equal(got, module) { | |
| t.Errorf("plain module.wasm was modified") | |
| } | |
| // A normal session also gets the real module. | |
| if got := get("/tok/module.wasm?s=1"); !bytes.Equal(got, module) { | |
| t.Errorf("normal session served a modified module") | |
| } | |
| // A corrupt session gets the module with the checksum overwritten. | |
| want := slices.Concat([]byte("\x00asm"), magic, bytes.Repeat([]byte("X"), 32), []byte("tail")) | |
| if got := get("/tok/module.wasm?s=2"); !bytes.Equal(got, want) { | |
| t.Errorf("corrupt session module = %q, want %q", got, want) | |
| } | |
| // The corrupt session advertises itself so the page reloads the module. | |
| srv.queue <- srv.sessions[2] | |
| var meta struct { | |
| ID int `json:"id"` | |
| Corrupt bool `json:"corrupt"` | |
| } | |
| if err := json.Unmarshal(get("/tok/session"), &meta); err != nil { | |
| t.Fatal(err) | |
| } | |
| if meta.ID != 2 || !meta.Corrupt { | |
| t.Errorf("session meta = %+v, want {ID:2 Corrupt:true}", meta) | |
| } | |
| } | |
| // TestClientInfo checks that a posted client environment is accepted. | |
| func TestClientInfo(t *testing.T) { | |
| srv := &server{ | |
| token: "tok", | |
| queue: make(chan *session, 64), | |
| sessions: make(map[int]*session), | |
| agents: make(map[string]bool), | |
| } | |
| ts := httptest.NewServer(srv.mux()) | |
| defer ts.Close() | |
| body := `{"userAgent":"UA/1.0","inferredBrowser":"Test 1"}` | |
| resp, err := http.Post(ts.URL+"/tok/clientinfo", "application/json", strings.NewReader(body)) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| resp.Body.Close() | |
| if resp.StatusCode != http.StatusOK { | |
| t.Errorf("clientinfo: HTTP %d, want 200", resp.StatusCode) | |
| } | |
| } | |
| func TestCrossOrigin(t *testing.T) { | |
| srv := &server{ | |
| token: "tok", | |
| queue: make(chan *session, 64), | |
| sessions: make(map[int]*session), | |
| agents: make(map[string]bool), | |
| } | |
| ts := httptest.NewServer(srv.mux()) | |
| defer ts.Close() | |
| req, err := http.NewRequest("POST", ts.URL+"/tok/io?s=1", strings.NewReader("x")) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| req.Header.Set("Origin", "http://evil.example") | |
| resp, err := http.DefaultClient.Do(req) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| resp.Body.Close() | |
| if resp.StatusCode != http.StatusForbidden { | |
| t.Errorf("cross-origin POST: HTTP %d, want 403", resp.StatusCode) | |
| } | |
| req.Header.Set("Origin", "http://"+req.Host) | |
| req.Body = io.NopCloser(strings.NewReader("x")) | |
| resp, err = http.DefaultClient.Do(req) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| resp.Body.Close() | |
| if resp.StatusCode != http.StatusNotFound { // no such session, but allowed | |
| t.Errorf("same-origin POST: HTTP %d, want 404", resp.StatusCode) | |
| } | |
| } |