|
1 | 1 | package main |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bytes" |
| 5 | + "mime/multipart" |
4 | 6 | "net/http" |
5 | 7 | "net/http/httptest" |
6 | 8 | "strings" |
@@ -124,3 +126,102 @@ func TestAuthMiddleware_Enabled_RejectsGarbageBearer(t *testing.T) { |
124 | 126 | rr := h.do(req) |
125 | 127 | assertStatus(t, rr, http.StatusUnauthorized) |
126 | 128 | } |
| 129 | + |
| 130 | +// --- AUTH_MODE=enabled — helper-identity defer-to-handler behavior --- |
| 131 | +// |
| 132 | +// These tests cover the bug where the middleware would 401 helper requests |
| 133 | +// that have identity headers but no app-password, breaking the auto-enroll |
| 134 | +// bootstrap flow. The middleware MUST defer to authorizeHelperSyncRequest |
| 135 | +// (in helper_auth.go), which enforces the actual policy. |
| 136 | + |
| 137 | +// helperMultipartSaveRequest builds a POST /saves multipart request that |
| 138 | +// looks like a helper upload — identity carried via headers, not form fields, |
| 139 | +// so the middleware's hasHelperIdentity check is exercised. Optionally sets |
| 140 | +// an Authorization Bearer with the supplied app-password. |
| 141 | +func helperMultipartSaveRequest(t *testing.T, deviceType, fingerprint, appPassword string) *http.Request { |
| 142 | + t.Helper() |
| 143 | + |
| 144 | + fields := map[string]string{ |
| 145 | + "rom_sha1": "middleware-helper-rom", |
| 146 | + "slotName": "default", |
| 147 | + "system": "snes", |
| 148 | + "runtimeProfile": "snes/snes9x", |
| 149 | + } |
| 150 | + |
| 151 | + var buf bytes.Buffer |
| 152 | + writer := multipart.NewWriter(&buf) |
| 153 | + for key, value := range fields { |
| 154 | + if err := writer.WriteField(key, value); err != nil { |
| 155 | + t.Fatalf("write multipart field %s: %v", key, err) |
| 156 | + } |
| 157 | + } |
| 158 | + part, err := writer.CreateFormFile("file", "Final Fantasy VI.srm") |
| 159 | + if err != nil { |
| 160 | + t.Fatalf("create multipart file: %v", err) |
| 161 | + } |
| 162 | + payload := normalizeTestUploadPayload(fields, "Final Fantasy VI.srm", []byte("helper-middleware-payload")) |
| 163 | + if _, err := part.Write(payload); err != nil { |
| 164 | + t.Fatalf("write multipart payload: %v", err) |
| 165 | + } |
| 166 | + if err := writer.Close(); err != nil { |
| 167 | + t.Fatalf("close multipart writer: %v", err) |
| 168 | + } |
| 169 | + |
| 170 | + req := httptest.NewRequest(http.MethodPost, "/saves", &buf) |
| 171 | + req.Header.Set("Content-Type", writer.FormDataContentType()) |
| 172 | + req.Header.Set("X-CSRF-Protection", "1") |
| 173 | + req.Header.Set("X-RSM-Device-Type", deviceType) |
| 174 | + req.Header.Set("X-RSM-Fingerprint", fingerprint) |
| 175 | + if appPassword != "" { |
| 176 | + req.Header.Set("Authorization", "Bearer "+appPassword) |
| 177 | + } |
| 178 | + return req |
| 179 | +} |
| 180 | + |
| 181 | +func TestAuthMiddleware_Enabled_HelperHeadersNoKey_AutoEnrollOpen_Allows(t *testing.T) { |
| 182 | + h := authMiddlewareHarness(t, true) |
| 183 | + // Open the auto-enroll window via the normal handler path. |
| 184 | + h.app.mu.Lock() |
| 185 | + h.app.enableAutoAppPasswordWindowLocked(15 * time.Minute) |
| 186 | + h.app.mu.Unlock() |
| 187 | + |
| 188 | + req := helperMultipartSaveRequest(t, "linux-x86", "deck-middleware-open", "") |
| 189 | + rr := h.do(req) |
| 190 | + assertStatus(t, rr, http.StatusOK) |
| 191 | + if got := rr.Header().Get("X-RSM-Auto-App-Password"); got == "" { |
| 192 | + t.Fatalf("expected auto-provisioned app password header on bootstrap; headers=%v", rr.Header()) |
| 193 | + } |
| 194 | +} |
| 195 | + |
| 196 | +func TestAuthMiddleware_Enabled_HelperHeadersNoKey_AutoEnrollClosed_Rejects(t *testing.T) { |
| 197 | + h := authMiddlewareHarness(t, true) |
| 198 | + // Default state: auto-enroll window is NOT open. |
| 199 | + |
| 200 | + req := helperMultipartSaveRequest(t, "linux-x86", "deck-middleware-closed", "") |
| 201 | + rr := h.do(req) |
| 202 | + // The middleware passes through; the downstream handler enforces the |
| 203 | + // no-key + closed-window policy with 401. |
| 204 | + assertStatus(t, rr, http.StatusUnauthorized) |
| 205 | +} |
| 206 | + |
| 207 | +func TestAuthMiddleware_Enabled_HelperHeadersValidKey_Allows(t *testing.T) { |
| 208 | + h := authMiddlewareHarness(t, true) |
| 209 | + key := mintAppPassword(t, h) |
| 210 | + |
| 211 | + req := helperMultipartSaveRequest(t, "linux-x86", "deck-middleware-key", key) |
| 212 | + rr := h.do(req) |
| 213 | + assertStatus(t, rr, http.StatusOK) |
| 214 | +} |
| 215 | + |
| 216 | +// Regression test for the GET /api/saves + valid Bearer path. The empirical |
| 217 | +// repro that surfaced the bootstrap bug had an empty Bearer (enrollment had |
| 218 | +// failed), so this confirms the happy path independently. |
| 219 | +func TestAuthMiddleware_Enabled_GetApiSavesWithValidBearer_Allows(t *testing.T) { |
| 220 | + h := authMiddlewareHarness(t, true) |
| 221 | + key := mintAppPassword(t, h) |
| 222 | + |
| 223 | + req := rawRequest(http.MethodGet, "/api/saves") |
| 224 | + req.Header.Set("Authorization", "Bearer "+key) |
| 225 | + rr := h.do(req) |
| 226 | + assertStatus(t, rr, http.StatusOK) |
| 227 | +} |
0 commit comments