eletrotupi / tcc/ commit / 49e58e0

api/frontend: carry the activation code as a string end to end

Codes starting with a zero were destroyed by the numeric round trip
between the app and the API, so those accounts could never be activated.
The code is now a validated string from the screen to the lookup, with
malformed input reported before any lookup runs
Pedro Lucas Porcellis porcellis@eletrotupi.com · 25 days ago 49e58e036a013f6830a02e9756dd173a285b7e5c Browse files
Parents 8f18bac
Changed files
6
Additions
+35
Deletions
-12
api/src/controllers/auth.ts
1 1
import { Request, Response, NextFunction } from "express";
2 2
import { AuthenticatedRequest } from "@app/middleware/auth";
3 3

4 4
import {
5 5
  login,
150 150

151 151
      if (!parsed.success) {
152 152
        return res.status(400).json(formatValidationError(parsed.error!));
153 153
      }
154 154

155 -
      const user = await findUserByActivationCode(String(parsed.data.code));
155 +
      const user = await findUserByActivationCode(parsed.data.code);
156 156

157 157
      if (!user) {
158 158
        return res.status(422).json({
159 159
          error: "Código incorreto. Verifique o código e tente novamente",
160 160
        });
198 198
    } catch (err: any) {
199 199
      next(err);
200 200
    }
201 201
  },
202 202
};
api/src/schemas/auth.schema.ts
1 1
import { z } from 'zod';
2 2

3 3
export const LoginSchema = z.object({
4 4
  email: z.string().email(),
5 5
  password: z.string().min(1)
13 13
  token: z.string(),
14 14
  newPassword: z.string().min(6)
15 15
});
16 16

17 17
export const ActivateUserSchema = z.object({
18 -
  code: z.number()
18 +
  code: z.string().regex(/^\d{6}$/)
19 19
});
20 20

21 21
export type LoginInput = z.infer<typeof LoginSchema>;
22 22
export type PasswordResetRequestInput = z.infer<typeof PasswordResetRequestSchema>;
23 23
export type PasswordResetInput = z.infer<typeof PasswordResetSchema>;
24 24
export type ActivateUserInput = z.infer<typeof ActivateUserSchema>;
api/tests/auth/activate.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 2
import request from 'supertest'
3 3
import { createApp } from '@app/createApp'
4 4
import {
5 5
  buildPrisma,
33 33
    const user = await createTestUser(db, { email: 'activate@example.com' })
34 34
    await seedActivationCode(user.id)
35 35

36 36
    const res = await request(app)
37 37
      .post('/auth/activate')
38 -
      .send({ code: 123456 })
38 +
      .send({ code: '123456' })
39 39

40 40
    expect(res.status).toBe(200)
41 41
    expect(res.body.user.active).toBe(true)
42 42
    expect(res.body.user.activationCode).toBeNull()
43 43

44 44
    const dbUser = await db.user.findUnique({ where: { id: user.id } })
45 45
    expect(dbUser!.active).toBe(true)
46 46
    expect(dbUser!.activationCode).toBeNull()
47 47
  })
48 48

49 -
  it('returns 422 for an unknown code', async () => {
49 +
  it('activates an account whose code begins with zero', async () => {
50 +
    const user = await createTestUser(db, { email: 'leading-zero@example.com' })
51 +
    await seedActivationCode(user.id, '012345')
52 +

53 +
    const res = await request(app)
54 +
      .post('/auth/activate')
55 +
      .send({ code: '012345' })
56 +

57 +
    expect(res.status).toBe(200)
58 +
    expect(res.body.user.active).toBe(true)
59 +

60 +
    const dbUser = await db.user.findUnique({ where: { id: user.id } })
61 +
    expect(dbUser!.active).toBe(true)
62 +
    expect(dbUser!.activationCode).toBeNull()
63 +
  })
64 +

65 +
  it('returns 422 for a well-formed but unknown code', async () => {
50 66
    await createTestUser(db, { email: 'activate@example.com' })
51 67

52 68
    const res = await request(app)
53 69
      .post('/auth/activate')
54 -
      .send({ code: 999999 })
70 +
      .send({ code: '999999' })
55 71

56 72
    expect(res.status).toBe(422)
57 73
    expect(res.body.error).toBeTruthy()
58 74
  })
59 75

60 -
  it('returns 400 when the code is not a number', async () => {
76 +
  it.each([
77 +
    ['too short', '12345'],
78 +
    ['too long', '1234567'],
79 +
    ['non-digit', '12a456'],
80 +
    ['a number', 123456],
81 +
  ])('rejects a code that is %s as malformed', async (_shape, code) => {
82 +
    await createTestUser(db, { email: 'activate@example.com' })
83 +

61 84
    const res = await request(app)
62 85
      .post('/auth/activate')
63 -
      .send({ code: '123456' })
86 +
      .send({ code })
64 87

65 88
    expect(res.status).toBe(400)
66 -
    expect(res.body.fields).toBeTruthy()
89 +
    expect(res.body.fields.code).toBeTruthy()
67 90
  })
68 91
})
69 92

70 93
describe('POST /auth/resend_code', () => {
71 94
  it('issues a fresh activation code for the authenticated user', async () => {
86 109
  it('returns 401 without a token', async () => {
87 110
    const res = await request(app).post('/auth/resend_code')
88 111
    expect(res.status).toBe(401)
89 112
  })
90 113
})
frontend/app/auth/activate.tsx
1 1
import React, { useState } from 'react';
2 2
import {
3 3
  View,
4 4
  StyleSheet,
5 5
  ScrollView,
46 46
  const handleActivate = async () => {
47 47
    if (!validateForm()) return;
48 48

49 49
    setLoading(true);
50 50
    try {
51 -
      await activate(Number(code.replaceAll(' ', '')));
51 +
      await activate(code);
52 52
      showToast('Conta ativada com sucesso!', 'success');
53 53
      router.replace('/');
54 54
    } catch (error: any) {
55 55
      showToast(translateError(error.message) || 'Falha em ativar a conta', 'error');
56 56
    } finally {
157 157
  footerText: {
158 158
    textAlign: 'center',
159 159
    marginBottom: 10
160 160
  }
161 161
});
frontend/context/AuthContext.tsx
1 1
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
2 2
import { apiClient, User } from "@/lib/api";
3 3
import { queryClient, storage } from "@/lib/queryClient";
4 4

5 5
interface AuthProviderProps {
21 21
  logout: () => Promise<void>;
22 22
  forgotPassword: (
23 23
    email: string,
24 24
  ) => Promise<{ message: string; token?: string }>;
25 25
  resetPassword: (token: string, password: string) => Promise<void>;
26 -
  activate: (code: number) => Promise<void>;
26 +
  activate: (code: string) => Promise<void>;
27 27
  requestActivateCode: () => Promise<void>;
28 28
}
29 29

30 30
export function sanitizeUser(data: any): User {
31 31
  return {
135 135

136 136
  const resetPassword = async (token: string, password: string) => {
137 137
    await apiClient.resetPassword(token, password);
138 138
  };
139 139

140 -
  const activate = async (code: number) => {
140 +
  const activate = async (code: string) => {
141 141
    const response = await apiClient.activate(code);
142 142
    setUser(response.user);
143 143
  };
144 144

145 145
  const requestActivateCode = async () => {
168 168
    [user, isLoading, isAuthenticated],
169 169
  );
170 170

171 171
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
172 172
};
frontend/lib/api/client.ts
1 1
import * as SecureStore from "expo-secure-store";
2 2

3 3
export class ApiError extends Error {
4 4
  status: number;
5 5
  fields?: Record<string, string>;
154 154
    }
155 155

156 156
    return response;
157 157
  }
158 158

159 -
  async activate(code: number): Promise<ActivateResponse> {
159 +
  async activate(code: string): Promise<ActivateResponse> {
160 160
    return this.request("/auth/activate", {
161 161
      method: "POST",
162 162
      body: JSON.stringify({ code }),
163 163
    });
164 164
  }
513 513
    return response.arrayBuffer()
514 514
  }
515 515
}
516 516

517 517
export const apiClient = new ApiClient();