carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net/http"
8 "net/url"
9 "strings"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "github.com/carlosframework/platform/internal/objstore"
15 "github.com/carlosframework/platform/internal/release"
16)
17
18// revivalMutate changes the store record from inside the engine's Status hook,
19// which runs on the handler's goroutine, so it reports rather than t.Fatal.
20func revivalMutate(fx *consoleFixture, f func(*objstore.Record)) error {
21 ctx := context.Background()
22 rec, etag, err := objstore.Get(ctx, fx.rel, release.OpsAccount, "titogo")
23 if err != nil {
24 return err
25 }
26 if rec == nil {
27 return errors.New("no record to mutate")
28 }
29 f(rec)
30 _, err = objstore.Put(ctx, fx.rel, release.OpsAccount, "titogo", rec, etag)
31 return err
32}
33
34func revivalTrash(fx *consoleFixture) error {
35 body, err := json.Marshal(release.AppTombstone{
36 Schema: 1, Account: release.OpsAccount, App: "titogo", DeletedAt: time.Now().UTC(), Actor: "test",
37 })
38 if err != nil {
39 return err
40 }
41 _, err = fx.rel.Put(context.Background(), release.AppTombstoneKey(release.OpsAccount, "titogo"), body, "")
42 return err
43}
44
45// TestRevivalLongPollRechecksConsentBeforeEveryRead: a consent change that lands
46// while a request is parked stops the next tag read, on both the wake path and
47// the final timeout reread, and is answered with the door's own refusal.
48func TestRevivalLongPollRechecksConsentBeforeEveryRead(t *testing.T) {
49 for _, tc := range []struct {
50 name string
51 wake bool
52 change func(*consoleFixture) error
53 wantStatus int
54 wantReason string
55 }{
56 {name: "revoke before wake", wake: true, wantStatus: http.StatusConflict, wantReason: "not_enabled",
57 change: func(fx *consoleFixture) error {
58 return revivalMutate(fx, func(r *objstore.Record) { r.Scanning.Enabled = nil })
59 }},
60 {name: "unrequest before final reread", wantStatus: http.StatusConflict, wantReason: "not_requested",
61 change: func(fx *consoleFixture) error {
62 return revivalMutate(fx, func(r *objstore.Record) { r.Scanning = nil })
63 }},
64 {name: "encrypted before wake", wake: true, wantStatus: http.StatusConflict, wantReason: "encrypted",
65 change: func(fx *consoleFixture) error {
66 return revivalMutate(fx, func(r *objstore.Record) { r.Scanning.Requested.Encrypted = true })
67 }},
68 {name: "disable before final reread", wantStatus: http.StatusServiceUnavailable, wantReason: "disabled",
69 change: func(fx *consoleFixture) error {
70 av, etag, err := release.GetAntivirus(context.Background(), fx.rel)
71 if err != nil || av == nil {
72 return errors.New("no antivirus record")
73 }
74 av.Enabled = false
75 _, err = release.PutAntivirus(context.Background(), fx.rel, av, etag)
76 return err
77 }},
78 {name: "trash before wake", wake: true, wantStatus: http.StatusNotFound, change: revivalTrash},
79 } {
80 t.Run(tc.name, func(t *testing.T) {
81 fx, sc, tok, bucket := statusArmed(t, "code-revival-"+strings.ReplaceAll(tc.name, " ", "-"))
82 v := seedObject(sc, bucket, "uploads/x.pdf", "etag-x")
83
84 // The change lands inside the FIRST read, after its consent check:
85 // that read is allowed, and every read after it must not happen.
86 // Any later read is handed a clean tag first, so a read that did
87 // happen would also show as a 200 clean.
88 var reads atomic.Int32
89 sc.OnStatus(func() {
90 if reads.Add(1) != 1 {
91 sc.Put(bucket, "uploads/x.pdf", v, objstore.VerdictClean)
92 return
93 }
94 if err := tc.change(fx); err != nil {
95 t.Errorf("change: %v", err)
96 }
97 if tc.wake {
98 fx.srv.scanNotify(objstore.AppRef{Account: release.OpsAccount, App: "titogo"}, "uploads/x.pdf")
99 }
100 })
101 wait := "400ms"
102 if tc.wake {
103 wait = "20s"
104 }
105
106 start := time.Now()
107 status, _, body := rawReq(t, http.MethodGet, enqueueURL(fx, "titogo")+"?key=uploads/x.pdf&wait="+wait, tok, "")
108 if status != tc.wantStatus {
109 t.Fatalf("status = %d %s, want %d", status, body, tc.wantStatus)
110 }
111 out := map[string]any{}
112 if status != http.StatusNotFound {
113 if err := json.Unmarshal([]byte(body), &out); err != nil {
114 t.Fatalf("decode %q: %v", body, err)
115 }
116 }
117 if tc.wantReason != "" && (out["reason"] != tc.wantReason || out["state"] != "unavailable") {
118 t.Errorf("answer = %v, want unavailable %s", out, tc.wantReason)
119 }
120 if n := reads.Load(); n != 1 {
121 t.Errorf("Status called %d times, want 1 — the door read the object after consent was withdrawn", n)
122 }
123 if tc.wake && time.Since(start) > 10*time.Second {
124 t.Errorf("took %v — the wake was not what returned it", time.Since(start))
125 }
126 })
127 }
128}
129
130// TestRevivalRecordedIsAJSONBoolean: `recorded` is a boolean on the wire. A
131// string "false" is truthy in most clients, which inverts its meaning.
132func TestRevivalRecordedIsAJSONBoolean(t *testing.T) {
133 ctx := context.Background()
134 fx, sc, tok, bucket := statusArmed(t, "code-revival-recorded")
135
136 vTag := seedObject(sc, bucket, "uploads/tagged.pdf", "etag-t")
137 sc.Put(bucket, "uploads/tagged.pdf", vTag, objstore.VerdictClean)
138 vRes := seedObject(sc, bucket, "uploads/untaggable.pdf", "etag-u")
139 if err := objstore.WriteScanResult(ctx, fx.rel, release.OpsAccount, "titogo", &objstore.ScanResult{
140 Key: "uploads/untaggable.pdf", Verdict: "infected", Version: vRes, Engine: "clamav", At: time.Now().UTC(),
141 }); err != nil {
142 t.Fatal(err)
143 }
144 seedObject(sc, bucket, "uploads/denied.pdf", "etag-d")
145 sc.DenyHead(bucket, "uploads/denied.pdf")
146 seedObject(sc, bucket, "uploads/pending.pdf", "etag-p")
147
148 for _, tc := range []struct {
149 key, state string
150 recorded bool
151 }{
152 {"uploads/tagged.pdf", "clean", true},
153 {"uploads/untaggable.pdf", "infected", false},
154 {"uploads/denied.pdf", "denied", false},
155 {"uploads/pending.pdf", "pending", false},
156 {"uploads/gone.pdf", "gone", false},
157 } {
158 status, _, body := rawReq(t, http.MethodGet, enqueueURL(fx, "titogo")+"?key="+url.QueryEscape(tc.key), tok, "")
159 if status != http.StatusOK {
160 t.Fatalf("%s = %d %s", tc.key, status, body)
161 }
162 var out struct {
163 State string `json:"state"`
164 Recorded json.RawMessage `json:"recorded"`
165 }
166 if err := json.Unmarshal([]byte(body), &out); err != nil {
167 t.Fatalf("%s: decode %q: %v", tc.key, body, err)
168 }
169 if out.State != tc.state {
170 t.Errorf("%s: state = %q, want %q", tc.key, out.State, tc.state)
171 }
172 want := "false"
173 if tc.recorded {
174 want = "true"
175 }
176 if string(out.Recorded) != want {
177 t.Errorf("%s: recorded = %s, want JSON boolean %s (body %s)", tc.key, out.Recorded, want, body)
178 }
179 }
180}
181
182// TestRevivalStatusKeyIsExact: the door asks about the key it was given, byte
183// for byte. A trimmed key would answer with another object's verdict.
184func TestRevivalStatusKeyIsExact(t *testing.T) {
185 fx, sc, tok, bucket := statusArmed(t, "code-revival-key")
186 v := seedObject(sc, bucket, "uploads/x.pdf", "etag-clean")
187 sc.Put(bucket, "uploads/x.pdf", v, objstore.VerdictClean)
188 seedObject(sc, bucket, " uploads/x.pdf ", "etag-padded")
189
190 if _, out := getStatus(t, fx, tok, "key="+url.QueryEscape("uploads/x.pdf")); out["state"] != "clean" {
191 t.Fatalf("control = %v, want clean", out)
192 }
193 if _, out := getStatus(t, fx, tok, "key="+url.QueryEscape(" uploads/x.pdf ")); out["state"] != "pending" {
194 t.Errorf("padded key = %v, want its own pending — not the trimmed key's clean", out)
195 }
196 if _, out := getStatus(t, fx, tok, "key="+url.QueryEscape("uploads/x.pdf\t")); out["state"] != "gone" {
197 t.Errorf("unseeded padded key = %v, want gone", out)
198 }
199 if status, _ := getStatus(t, fx, tok, "key="+url.QueryEscape(" \t ")); status != http.StatusBadRequest {
200 t.Errorf("all-whitespace key = %d, want 400", status)
201 }
202}
203